@arcadiasystems/morse-uploader 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  All notable changes to `@arcadiasystems/morse-uploader` will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [0.4.0] - 2026-10-20
6
+
7
+ Downloads now tell an encrypted file from a public one by its on-chain Seal prefix instead of trusting the share link, and uploads refuse a file too large for the relay's tip cap before reading it. Peer dependencies are unchanged. This is a minor bump because it adds public exports and narrows `walrusWriteConfig.uploadRelay.sendTip` from `unknown`, so a value `@mysten/walrus` would not accept no longer type-checks.
8
+
9
+ ### Fixed
10
+
11
+ - **A share link that lost its Seal material served an encrypted file as if it were public.** `useFileDownload` decided whether to decrypt from the link alone, so a link that arrived without its `p` and `n` parameters (a chat client trimming the fragment, a URL copied by hand, a link rebuilt from the file id) read the bytes straight from Walrus and handed back ciphertext as the file, with no error anywhere. `load` and `download` now first read the file's Seal prefix from chain with the SDK's `getRecipientFileSealPrefix`, the only reliable sign that a file is encrypted (`members` is not one, because the owner is always a member). A file with an on-chain prefix opened from a link without Seal material now fails with a `ShareLinkSealError` and serves nothing, and `MorseFileDownloader` shows "Link is incomplete" with "This file is encrypted, and the link is missing the details needed to decrypt it. Ask the sender for the full link."
12
+ - **A link whose Seal prefix is not the file's is refused before the wallet is asked to sign.** Such a link cannot decrypt the file, but it used to cost a SessionKey signature and then fail at the key servers with what reads as a permissions error, blaming the wallet for a damaged link. It now fails with `ShareLinkSealError` reason `"mismatch"`, shown as "Link does not match this file". The on-chain prefix is deliberately not substituted for the link's: the nonce is not on chain, so a link with one damaged field cannot be told apart from a link to a different file, and the uploader already refuses half-damaged links rather than guessing (see 0.2.0's `parseShareLink` fix).
13
+
14
+ A file with no on-chain prefix behaves as before. Files encrypted before the prefix was stored on chain have none, and their links still carry the Seal material, so a missing prefix does not prove a file is public and the link still decides.
15
+
16
+ The prefix is read only when the bytes are fetched, so a page that renders just the metadata pays nothing extra; a public download costs one more round trip and an encrypted one two. The read is aborted when the component unmounts, and an aborted fetch stops before reading bytes or prompting the wallet.
17
+ - **A file too large for the relay's tip cap was read, encrypted and encoded before the upload failed, and the failure read as a network issue.** The Walrus Mainnet relay's tip grows with the blob's encoded size, so `sendTip.max` also caps the file size: 10,000,000 MIST, the cap the README and the example use, allows 42,327,820 bytes (40.37 MiB). `@mysten/walrus` enforces the cap only when it builds the register transaction, after encoding the whole file, and the SDK passed its "Tip amount (N) exceeds the maximum allowed tip (M)" on as a `TransportError`, which `uploadErrorMessage` titled "Network issue". `useFileUpload` now reads the relay's `/v1/tip-config` first, derives the largest file the cap allows with the SDK's encoded-size formula, and refuses a larger file with a `FileTooLargeError` before reading it. `MorseFileUploader` shows "File too large" with the file's size, the limit and the cap.
18
+
19
+ An encrypted file that fits, but that Seal's envelope takes over the limit, is refused after encryption and before Walrus encodes it, since the envelope's size depends on the key servers. A cap below the tip for even the smallest file (the example's 1,000,000 MIST before 0.2.0) now fails with a `ConfigurationError` saying so. Testnet's relay charges a flat 105 MIST, so there the cap sets no size limit. Each upload through a relay with a `sendTip.max` costs one extra request for the tip config; without a relay or a cap, nothing is requested.
20
+
21
+ ### Added
22
+
23
+ - **`downloadErrorMessage(error)`**, used by `MorseFileDownloader` and exported for custom UIs built on `useFileDownload`. It titles a `ShareLinkSealError` by its reason and defers everything else to the SDK's `formatUserMessage`, which would title it "Unexpected error".
24
+ - **`ShareLinkSealError`**, with `reason: ShareLinkSealErrorReason` (`"missing" | "mismatch"`). `useFileDownload` sets it as `error` when the link cannot open an encrypted file.
25
+ - **`FileTooLargeError`**, with `sizeBytes`, `afterEncryption` and `limit: UploadSizeLimit` (`maxBytes`, `maxTipMist`). `useFileUpload` throws it when a file is too large for the relay's tip cap, and `uploadErrorMessage` titles it "File too large".
26
+ - **`uploadRelay` on `MorseFilesHandles`**, the relay `walrusWrite` sends blobs through. `useMorseFiles` sets it from `walrusWriteConfig.uploadRelay` when it builds the write adapter. It stays unset for a prebuilt `walrusWrite`, whose relay the package cannot see, so the size check is skipped and the SDK's own tip check still applies. Hand-built handles can set it to get the check.
27
+
28
+ ### Changed
29
+
30
+ - **`walrusWriteConfig.uploadRelay.sendTip` is typed** as `@mysten/walrus`'s `UploadRelayConfig["sendTip"]` instead of `unknown`, and the relay options are exported as `UploadRelayOptions`. Every value `@mysten/walrus` accepts still type-checks; the package now reads `max` from it.
31
+
5
32
  ## [0.3.0] - 2026-10-18
6
33
 
7
34
  Moves to `@arcadiasystems/morse-sdk` 0.9 and the Mysten lines it requires. The public API is unchanged. This is a minor bump because the new peer ranges exclude every version 0.2.x accepted, so upgrading means upgrading the SDK and all three Mysten packages with it.
package/README.md CHANGED
@@ -60,6 +60,7 @@ function FilesApp({ account }) {
60
60
  uploadRelay: {
61
61
  host: "https://upload-relay.testnet.walrus.space",
62
62
  // Caps the per-upload tip the Walrus Mainnet relay charges, in MIST (0.01 SUI).
63
+ // The tip grows with file size, so this also caps the file: see "Notes on Walrus".
63
64
  sendTip: { max: 10_000_000 },
64
65
  },
65
66
  },
@@ -113,8 +114,8 @@ All hooks must be used inside `MorseFilesProvider`.
113
114
  | Hook | Purpose |
114
115
  |---|---|
115
116
  | `useMorseFiles(options)` | Build the handles from a connected wallet. Used once, above the provider. |
116
- | `useFileUpload()` | Upload one file: `upload({ file, epochs, recipients?, encrypt? })`. Encrypted when `encrypt` is set. Returns `{ fileId, blobId, encrypted, sealIdPrefix, sealNonce, shareLink }` and exposes a phase stepper. |
117
- | `useFileDownload(fileId, seal)` | Drive a download page: loads metadata, then `load()` / `download()` reads (public, `seal` null) or decrypts (encrypted, `seal` = `{ sealIdPrefix, sealNonce }`). |
117
+ | `useFileUpload()` | Upload one file: `upload({ file, epochs, recipients?, encrypt? })`. Encrypted when `encrypt` is set. Returns `{ fileId, blobId, encrypted, sealIdPrefix, sealNonce, shareLink }` and exposes a phase stepper. Refuses a file too large for the relay's tip cap with `FileTooLargeError` before reading it. |
118
+ | `useFileDownload(fileId, seal)` | Drive a download page: loads metadata, then `load()` / `download()` reads (public, `seal` null) or decrypts (encrypted, `seal` = `{ sealIdPrefix, sealNonce }`). It first reads the file's Seal prefix from chain, so a file that has one, opened with `seal` null or with another file's prefix, fails with `ShareLinkSealError` instead of serving ciphertext. |
118
119
  | `useFileDecrypt()` | Lower-level decrypt: `decrypt({ file, sealIdPrefix, sealNonce })` returns plaintext bytes (one SessionKey signature, reused across files). |
119
120
  | `useRecipientFile(fileId)` | Load a `RecipientFile`'s on-chain metadata (name, contentType, size, members, blobId). |
120
121
  | `useStorageCostEstimate({ sizeBytes, epochs, encrypt })` | USD storage-cost estimate using Walrus [predictable pricing](https://blog.walrus.xyz/announcing-predictable-pricing-in-usd-on-walrus/) ($0.023/GB/month). |
@@ -123,7 +124,11 @@ All hooks must be used inside `MorseFilesProvider`.
123
124
 
124
125
  ### Rendering an upload error
125
126
 
126
- `uploadErrorMessage(error, network)` returns the SDK's `{ title, description, cause }` for any failure, with one rewrite: an insufficient-balance failure arrives as raw `@mysten/sui` text (a coin type, an address, two unscaled integers) that the SDK can only classify as a network problem. This restates it as "Not enough WAL", with the amounts in whole coins and the network's way of topping up. `MorseFileUploader` already uses it; call it directly if you render your own errors.
127
+ `uploadErrorMessage(error, network)` returns the SDK's `{ title, description, cause }` for any failure, with two exceptions. An insufficient-balance failure arrives as raw `@mysten/sui` text (a coin type, an address, two unscaled integers) that the SDK can only classify as a network problem. This restates it as "Not enough WAL", with the amounts in whole coins and the network's way of topping up. A `FileTooLargeError` (see [Notes on Walrus](#notes-on-walrus)) is titled "File too large", with the file's size, the limit and the tip cap. `MorseFileUploader` already uses it; call it directly if you render your own errors.
128
+
129
+ ### Rendering a download error
130
+
131
+ `downloadErrorMessage(error)` does the same for `useFileDownload`'s `error`. A `ShareLinkSealError` means the file is encrypted and the link cannot open it: `reason` is `"missing"` when the link has no Seal material and `"mismatch"` when its prefix is not the file's. These are titled "Link is incomplete" and "Link does not match this file", and both ask the reader to get the full link from the sender. Everything else goes to the SDK's `formatUserMessage`. `MorseFileDownloader` already uses it.
127
132
 
128
133
  ## How encryption works
129
134
 
@@ -152,6 +157,7 @@ const parsed = parseShareLink(window.location.href);
152
157
 
153
158
  - Storage is a **lease**, capped at 53 epochs ahead on both networks. An epoch is ~1 day on testnet and ~2 weeks on mainnet.
154
159
  - Blobs are raw bytes with no filename or MIME type; this package stores the name and content type in the on-chain file record so downloads come back correctly named and typed.
160
+ - **The relay's tip cap is also a file-size cap.** The Walrus Mainnet relay charges a tip that grows with the blob's encoded size (its `/v1/tip-config` asks for 40 MIST per encoded KiB), and `@mysten/walrus` refuses a tip above `sendTip.max`. At the 10,000,000 MIST cap above, the largest file is 42,327,820 bytes (40.37 MiB). `useFileUpload` reads the relay's tip config before each upload and refuses a larger file with a `FileTooLargeError` before reading it, rather than letting the SDK encode the whole file first. An encrypted file is checked again after encryption, since Seal's envelope adds a few hundred bytes. Raise `sendTip.max` to allow larger files. Testnet's relay charges a flat 105 MIST, so there the cap sets no size limit. Handles built around a prebuilt `walrusWrite` have no `uploadRelay`, so this check is skipped, and the SDK's own check still refuses the tip before anything is signed.
155
161
 
156
162
  ## Seal on mainnet
157
163
 
@@ -1,4 +1,4 @@
1
- import { formatUserMessage, buildRecipientFileSealId, SealError, uploadEncryptedRecipientFileFromBytes, uploadRecipientFileFromBytes } from '@arcadiasystems/morse-sdk';
1
+ import { formatUserMessage, buildRecipientFileSealId, SealError, uploadEncryptedRecipientFileFromBytes, uploadRecipientFileFromBytes, ConfigurationError, TransportError } from '@arcadiasystems/morse-sdk';
2
2
  import { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
3
3
  import { SessionKey, UserError } from '@mysten/seal';
4
4
  import { normalizeSuiAddress } from '@mysten/sui/utils';
@@ -49,6 +49,34 @@ function buildShareLink(args) {
49
49
  return `${base}${path}${query}`;
50
50
  }
51
51
 
52
+ // src/files/share-link-seal-error.ts
53
+ var MESSAGES = {
54
+ missing: "This file is encrypted, and the link is missing the details needed to decrypt it. Ask the sender for the full link.",
55
+ mismatch: "This file is encrypted, and the link's decryption details are for a different file. The link may have been damaged or mixed up with another one. Ask the sender for the full link."
56
+ };
57
+ var ShareLinkSealError = class extends Error {
58
+ reason;
59
+ constructor(reason) {
60
+ super(MESSAGES[reason]);
61
+ this.name = "ShareLinkSealError";
62
+ this.reason = reason;
63
+ }
64
+ };
65
+ var TITLES = {
66
+ missing: "Link is incomplete",
67
+ mismatch: "Link does not match this file"
68
+ };
69
+ function downloadErrorMessage(error) {
70
+ if (!(error instanceof ShareLinkSealError)) {
71
+ return formatUserMessage(error);
72
+ }
73
+ return {
74
+ title: TITLES[error.reason],
75
+ description: error.message,
76
+ cause: error
77
+ };
78
+ }
79
+
52
80
  // src/files/epoch-duration-ms.ts
53
81
  var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
54
82
  var EPOCH_DURATION_MS = {
@@ -73,7 +101,53 @@ function estimateStorageUsd(args) {
73
101
  const rate = args.usdPerGbPerMonth ?? DEFAULT_USD_PER_GB_PER_MONTH;
74
102
  return { usd: gb * months * rate, gb, months, days, sizeBytes };
75
103
  }
104
+
105
+ // src/utils/format-bytes.ts
106
+ var UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
107
+ var BASE = 1024;
108
+ function formatBytes(bytes, fractionDigits = 2) {
109
+ if (!Number.isFinite(bytes) || bytes <= 0) {
110
+ return "0 B";
111
+ }
112
+ const exponent = Math.min(
113
+ Math.floor(Math.log(bytes) / Math.log(BASE)),
114
+ UNITS.length - 1
115
+ );
116
+ const value = bytes / BASE ** exponent;
117
+ const unit = UNITS[exponent] ?? "B";
118
+ if (exponent === 0) {
119
+ return `${value} ${unit}`;
120
+ }
121
+ return `${value.toFixed(fractionDigits)} ${unit}`;
122
+ }
123
+
124
+ // src/files/file-too-large-error.ts
125
+ var FileTooLargeError = class extends Error {
126
+ sizeBytes;
127
+ afterEncryption;
128
+ limit;
129
+ constructor(args) {
130
+ const subject = args.afterEncryption ? "Encrypted, this file is" : "This file is";
131
+ super(
132
+ `${subject} ${describeBytes(args.sizeBytes)}, over the ${describeBytes(args.limit.maxBytes)} limit for one upload. The Walrus upload relay's tip grows with file size, and this app caps it at ${args.limit.maxTipMist.toLocaleString("en-US")} MIST.`
133
+ );
134
+ this.name = "FileTooLargeError";
135
+ this.sizeBytes = args.sizeBytes;
136
+ this.afterEncryption = args.afterEncryption;
137
+ this.limit = args.limit;
138
+ }
139
+ };
140
+ function describeBytes(bytes) {
141
+ return `${formatBytes(bytes)} (${bytes.toLocaleString("en-US")} bytes)`;
142
+ }
76
143
  function uploadErrorMessage(error, network) {
144
+ if (error instanceof FileTooLargeError) {
145
+ return {
146
+ title: "File too large",
147
+ description: error.message,
148
+ cause: error
149
+ };
150
+ }
77
151
  const shortfall = parseInsufficientBalance(errorText(error));
78
152
  if (shortfall === null) {
79
153
  return formatUserMessage(error);
@@ -334,29 +408,65 @@ function useRecipientFile(fileId) {
334
408
  }, [refresh, fileId]);
335
409
  return { status, file, error, refresh };
336
410
  }
411
+
412
+ // src/utils/bytes-equal.ts
413
+ function bytesEqual(a, b) {
414
+ if (a.length !== b.length) {
415
+ return false;
416
+ }
417
+ return a.every((byte, index) => byte === b[index]);
418
+ }
419
+
420
+ // src/files/assert-share-link-opens-file.ts
421
+ function assertShareLinkOpensFile(onChainPrefix, seal) {
422
+ if (onChainPrefix === null) {
423
+ return;
424
+ }
425
+ if (seal === null) {
426
+ throw new ShareLinkSealError("missing");
427
+ }
428
+ if (!bytesEqual(seal.sealIdPrefix, onChainPrefix)) {
429
+ throw new ShareLinkSealError("mismatch");
430
+ }
431
+ }
432
+
433
+ // src/files/use-file-download.ts
337
434
  function useFileDownload(fileId, seal) {
338
- const { walrusRead } = useMorseFilesContext();
435
+ const { reader, walrusRead } = useMorseFilesContext();
339
436
  const { status: metaStatus, file } = useRecipientFile(fileId);
340
437
  const { decrypt } = useFileDecrypt();
341
438
  const [status, setStatus] = useState("idle");
342
439
  const [objectUrl, setObjectUrl] = useState(null);
343
440
  const [error, setError] = useState(null);
344
441
  const urlRef = useRef(null);
442
+ const lifetimeRef = useRef(null);
345
443
  useEffect(() => {
346
444
  if (objectUrl === null) {
347
445
  return;
348
446
  }
349
447
  return () => URL.revokeObjectURL(objectUrl);
350
448
  }, [objectUrl]);
449
+ useEffect(() => {
450
+ const controller = new AbortController();
451
+ lifetimeRef.current = controller;
452
+ return () => controller.abort();
453
+ }, []);
351
454
  const encrypted = seal !== null;
352
455
  const isImage = file === null ? false : isPreviewableImage(file.contentType);
353
456
  const fetchToUrl = useCallback(async () => {
354
457
  if (file === null) {
355
458
  return null;
356
459
  }
460
+ const signal = lifetimeRef.current?.signal;
357
461
  setStatus("loading");
358
462
  setError(null);
359
463
  try {
464
+ const onChainPrefix = await reader.getRecipientFileSealPrefix(
465
+ file.id,
466
+ signal
467
+ );
468
+ signal?.throwIfAborted();
469
+ assertShareLinkOpensFile(onChainPrefix, seal);
360
470
  let bytes;
361
471
  if (seal !== null) {
362
472
  bytes = await decrypt({
@@ -375,11 +485,14 @@ function useFileDownload(fileId, seal) {
375
485
  setStatus("ready");
376
486
  return url;
377
487
  } catch (err) {
488
+ if (signal?.aborted === true) {
489
+ return null;
490
+ }
378
491
  setError(err);
379
492
  setStatus("error");
380
493
  return null;
381
494
  }
382
- }, [file, seal, decrypt, walrusRead]);
495
+ }, [file, seal, reader, decrypt, walrusRead]);
383
496
  const load = useCallback(async () => {
384
497
  await fetchToUrl();
385
498
  }, [fetchToUrl]);
@@ -407,10 +520,145 @@ function useFileDownload(fileId, seal) {
407
520
  download
408
521
  };
409
522
  }
523
+ var OPERATION = "walrus.relay.tipConfig";
524
+ var TIMEOUT_MS = 3e4;
525
+ async function fetchRelayTipStrategy(host) {
526
+ let response;
527
+ try {
528
+ response = await fetch(`${host}/v1/tip-config`, {
529
+ signal: AbortSignal.timeout(TIMEOUT_MS)
530
+ });
531
+ } catch (cause) {
532
+ throw new TransportError(
533
+ `Could not reach the upload relay at ${host} to read its tip config.`,
534
+ { cause, operation: OPERATION }
535
+ );
536
+ }
537
+ if (!response.ok) {
538
+ throw new TransportError(
539
+ `The upload relay at ${host} answered HTTP ${response.status} when asked for its tip config.`,
540
+ { operation: OPERATION }
541
+ );
542
+ }
543
+ let body;
544
+ try {
545
+ body = await response.json();
546
+ } catch (cause) {
547
+ throw unreadable(host, cause);
548
+ }
549
+ if (typeof body === "string") {
550
+ return null;
551
+ }
552
+ const kind = field(field(body, "send_tip"), "kind");
553
+ const constant = field(kind, "const");
554
+ if (isMist(constant)) {
555
+ return { const: constant };
556
+ }
557
+ const linear = field(kind, "linear");
558
+ const base = field(linear, "base");
559
+ const perEncodedKib = field(linear, "encoded_size_mul_per_kib");
560
+ if (isMist(base) && isMist(perEncodedKib)) {
561
+ return { linear: { base, perEncodedKib } };
562
+ }
563
+ throw unreadable(host);
564
+ }
565
+ function field(value, key) {
566
+ if (typeof value !== "object" || value === null) {
567
+ return void 0;
568
+ }
569
+ return value[key];
570
+ }
571
+ function isMist(value) {
572
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
573
+ }
574
+ function unreadable(host, cause) {
575
+ return new TransportError(
576
+ `The upload relay at ${host} sent a tip config this version cannot read.`,
577
+ cause === void 0 ? { operation: OPERATION } : { cause, operation: OPERATION }
578
+ );
579
+ }
580
+
581
+ // src/files/max-blob-bytes-for-tip.ts
582
+ var N_SHARDS = 1e3;
583
+ var MAX_FAULTY_SHARDS = Math.floor((N_SHARDS - 1) / 3);
584
+ var PRIMARY_SYMBOLS = N_SHARDS - 2 * MAX_FAULTY_SHARDS;
585
+ var SECONDARY_SYMBOLS = N_SHARDS - MAX_FAULTY_SHARDS;
586
+ var BYTES_PER_SYMBOL = BigInt(PRIMARY_SYMBOLS * SECONDARY_SYMBOLS);
587
+ var ENCODED_BYTES_PER_SYMBOL = BigInt(
588
+ (PRIMARY_SYMBOLS + SECONDARY_SYMBOLS) * N_SHARDS
589
+ );
590
+ var METADATA_BYTES = BigInt(N_SHARDS * (N_SHARDS * 32 * 2 + 32));
591
+ var BYTES_PER_KIB = 1024n;
592
+ function maxBlobBytesForTip(strategy, maxTipMist) {
593
+ if ("const" in strategy) {
594
+ return BigInt(strategy.const) <= maxTipMist ? null : 0;
595
+ }
596
+ const base = BigInt(strategy.linear.base);
597
+ const perEncodedKib = BigInt(strategy.linear.perEncodedKib);
598
+ if (perEncodedKib === 0n) {
599
+ return base <= maxTipMist ? null : 0;
600
+ }
601
+ const maxEncodedBytes = (maxTipMist - base) / perEncodedKib * BYTES_PER_KIB;
602
+ if (maxEncodedBytes < METADATA_BYTES) {
603
+ return 0;
604
+ }
605
+ const symbols = (maxEncodedBytes - METADATA_BYTES) / ENCODED_BYTES_PER_SYMBOL;
606
+ const evenSymbols = symbols - symbols % 2n;
607
+ return Number(evenSymbols * BYTES_PER_SYMBOL);
608
+ }
609
+
610
+ // src/files/resolve-upload-size-limit.ts
611
+ async function resolveUploadSizeLimit(relay) {
612
+ if (relay === void 0) {
613
+ return null;
614
+ }
615
+ const { sendTip } = relay;
616
+ if (sendTip === void 0 || sendTip === null) {
617
+ return null;
618
+ }
619
+ if (sendTip.max === void 0 || !Number.isFinite(sendTip.max)) {
620
+ return null;
621
+ }
622
+ const maxTipMist = BigInt(Math.floor(sendTip.max));
623
+ const strategy = "kind" in sendTip ? sendTip.kind : await fetchRelayTipStrategy(relay.host);
624
+ if (strategy === null) {
625
+ return null;
626
+ }
627
+ const maxBytes = maxBlobBytesForTip(strategy, maxTipMist);
628
+ if (maxBytes === null) {
629
+ return null;
630
+ }
631
+ if (maxBytes === 0) {
632
+ throw new ConfigurationError(
633
+ `sendTip.max is ${maxTipMist.toLocaleString("en-US")} MIST, below the tip the upload relay at ${relay.host} charges for even the smallest file, so it would refuse every upload. Raise sendTip.max in walrusWriteConfig.uploadRelay.`
634
+ );
635
+ }
636
+ return { maxBytes, maxTipMist };
637
+ }
638
+
639
+ // src/files/with-ciphertext-size-limit.ts
640
+ function withCiphertextSizeLimit(walrus, limit) {
641
+ return {
642
+ uploadBlob: (data, options) => walrus.uploadBlob(data, options),
643
+ uploadQuilt: (patches, options) => walrus.uploadQuilt(patches, options),
644
+ startBlobUpload: async (data, options) => {
645
+ if (data.length > limit.maxBytes) {
646
+ throw new FileTooLargeError({
647
+ sizeBytes: data.length,
648
+ afterEncryption: true,
649
+ limit
650
+ });
651
+ }
652
+ return walrus.startBlobUpload(data, options);
653
+ }
654
+ };
655
+ }
656
+
657
+ // src/files/use-file-upload.ts
410
658
  var SEAL_UNAVAILABLE2 = "No Seal key servers are configured, so encrypted files are unavailable. Testnet pins an open set; mainnet pins none, because every operator there is commercial. Pass `sealKeyServers` to MorseFilesProvider, starting from the SDK's MAINNET_SEAL_COMMITTEE with the apiKeyName and apiKey your operator issues.";
411
659
  var FALLBACK_CONTENT_TYPE = "application/octet-stream";
412
660
  function useFileUpload() {
413
- const { adapter, config, walrusWrite, seal, network } = useMorseFilesContext();
661
+ const { adapter, config, walrusWrite, uploadRelay, seal, network } = useMorseFilesContext();
414
662
  const [status, setStatus] = useState("idle");
415
663
  const [phase, setPhase] = useState(null);
416
664
  const [data, setData] = useState(null);
@@ -428,6 +676,14 @@ function useFileUpload() {
428
676
  setData(null);
429
677
  setError(null);
430
678
  try {
679
+ const sizeLimit = await resolveUploadSizeLimit(uploadRelay);
680
+ if (sizeLimit !== null && input.file.size > sizeLimit.maxBytes) {
681
+ throw new FileTooLargeError({
682
+ sizeBytes: input.file.size,
683
+ afterEncryption: false,
684
+ limit: sizeLimit
685
+ });
686
+ }
431
687
  const bytes = new Uint8Array(await input.file.arrayBuffer());
432
688
  const contentType = input.file.type || FALLBACK_CONTENT_TYPE;
433
689
  const recipients = input.recipients ?? [];
@@ -445,7 +701,7 @@ function useFileUpload() {
445
701
  adapter,
446
702
  config,
447
703
  {
448
- walrus: walrusWrite,
704
+ walrus: sizeLimit === null ? walrusWrite : withCiphertextSizeLimit(walrusWrite, sizeLimit),
449
705
  seal,
450
706
  plaintext: bytes,
451
707
  recipients,
@@ -496,7 +752,7 @@ function useFileUpload() {
496
752
  throw err;
497
753
  }
498
754
  },
499
- [adapter, config, walrusWrite, seal, network]
755
+ [adapter, config, walrusWrite, uploadRelay, seal, network]
500
756
  );
501
757
  return { status, phase, data, error, upload, reset };
502
758
  }
@@ -628,25 +884,6 @@ function useDropzone(options) {
628
884
  return { isDragging, open, getRootProps, getInputProps, inputRef };
629
885
  }
630
886
 
631
- // src/utils/format-bytes.ts
632
- var UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
633
- var BASE = 1024;
634
- function formatBytes(bytes, fractionDigits = 2) {
635
- if (!Number.isFinite(bytes) || bytes <= 0) {
636
- return "0 B";
637
- }
638
- const exponent = Math.min(
639
- Math.floor(Math.log(bytes) / Math.log(BASE)),
640
- UNITS.length - 1
641
- );
642
- const value = bytes / BASE ** exponent;
643
- const unit = UNITS[exponent] ?? "B";
644
- if (exponent === 0) {
645
- return `${value} ${unit}`;
646
- }
647
- return `${value.toFixed(fractionDigits)} ${unit}`;
648
- }
649
-
650
887
  // src/utils/format-usd.ts
651
888
  var TINY_FLOOR = 1e-6;
652
889
  function formatUsd(usd) {
@@ -665,6 +902,6 @@ function formatUsd(usd) {
665
902
  })}`;
666
903
  }
667
904
 
668
- export { DEFAULT_ENCRYPTION_OVERHEAD_BYTES, MorseFilesContext, buildShareLink, bytesToHex, epochDurationMs, estimateStorageUsd, formatBytes, formatUsd, isPreviewableImage, resolveOrigin, triggerDownload, uploadErrorMessage, uploadPhaseLabel, uploadPhases, useClipboard, useDropzone, useFileDecrypt, useFileDownload, useFileUpload, useMorseFilesContext, useRecipientFile, useStorageCostEstimate };
669
- //# sourceMappingURL=chunk-K2J3ZILI.js.map
670
- //# sourceMappingURL=chunk-K2J3ZILI.js.map
905
+ export { DEFAULT_ENCRYPTION_OVERHEAD_BYTES, FileTooLargeError, MorseFilesContext, ShareLinkSealError, buildShareLink, bytesToHex, downloadErrorMessage, epochDurationMs, estimateStorageUsd, formatBytes, formatUsd, isPreviewableImage, resolveOrigin, triggerDownload, uploadErrorMessage, uploadPhaseLabel, uploadPhases, useClipboard, useDropzone, useFileDecrypt, useFileDownload, useFileUpload, useMorseFilesContext, useRecipientFile, useStorageCostEstimate };
906
+ //# sourceMappingURL=chunk-ZV3GFXNW.js.map
907
+ //# sourceMappingURL=chunk-ZV3GFXNW.js.map