@arcadiasystems/morse-uploader 0.2.1 → 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,44 @@
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
+
32
+ ## [0.3.0] - 2026-10-18
33
+
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.
35
+
36
+ ### Changed
37
+
38
+ - **Peer dependencies.** `@arcadiasystems/morse-sdk` `>=0.9.0 <0.10.0` (was `>=0.8.1 <0.9.0`), `@mysten/sui` `>=2.36.0 <2.37.0` (was `>=2.16.2 <2.17.0`), `@mysten/walrus` `>=1.2.35 <1.3.0` (was `>=1.1.6 <1.2.0`), `@mysten/seal` `>=1.5.0 <1.6.0` (was `>=1.1.3 <1.2.0`). These are the SDK's own peer ranges, so an app resolves one copy of each. The README peer install is now `npm install react react-dom @arcadiasystems/morse-sdk@~0.9.0 @mysten/sui@~2.36.0 @mysten/walrus@~1.2.35 @mysten/seal@~1.5.0`.
39
+ - **`useFileDecrypt` now checks that the signer belongs to the session address itself.** `@mysten/seal` 1.5 removed that check from `SessionKey.create` to support address aliases. `useFileDecrypt` caches the session per address, so handles whose `signer` and `address` disagree would have cost a wallet popup, failed at the key servers, and stayed cached, failing every later decrypt for that address the same way. The hook now rejects before creating the session, with the same `UserError` class and leading message Seal used to throw, and without prompting the wallet or caching anything. Behaviour therefore matches 0.2.x. Handles from `useMorseFiles` always agree, because the SDK's `WalletStandardSigner` requires the account key to derive to its address, so only hand-built handles can reach the check. A signer whose key is an alias of the address stays unsupported, as it was before.
40
+ - **Everything else upstream changed needs nothing from consumers.** Seal 1.3 turned `verifyKeyServers` off by default, but the uploader always builds Seal through the SDK's `DefaultSealAdapter`, which now sets it to `true`, so key servers are still verified on first use. The `@mysten/sui` 2.26 `ObjectError` change is absorbed by the SDK's reader. The insufficient-balance text `uploadErrorMessage` rewrites is unchanged in sui 2.36, and walrus 1.2.35 still types the relay's `sendTip` as `{ max: number }`, so the README's `sendTip: { max: 10_000_000 }` stands.
41
+ - **The example** pins the same versions, plus `@mysten/dapp-kit` 1.1.17 and `@mysten/walrus-wasm` 0.3.1, the line walrus 1.2.35 uses. dapp-kit 1.1.17 is the last release of that package: Mysten has deprecated it because it supports JSON-RPC only, so the README's note on pointing it at a JSON-RPC endpoint still applies.
42
+
5
43
  ## [0.2.1] - 2026-10-17
6
44
 
7
45
  README fixes only. The README ships in the package, so these needed a release; no code changed.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @arcadiasystems/morse-uploader
2
2
 
3
- Headless-first React components and hooks for encrypted file sharing on Sui and Walrus, built on [`@arcadiasystems/morse-sdk`](https://github.com/arcadiasystems/morse-dcms) 0.8. Upload a file to Walrus, optionally encrypt it for a set of recipient wallets so only they can decrypt, and share a link. The connected wallet pays for and signs everything.
3
+ Headless-first React components and hooks for encrypted file sharing on Sui and Walrus, built on [`@arcadiasystems/morse-sdk`](https://github.com/arcadiasystems/morse-dcms) 0.9. Upload a file to Walrus, optionally encrypt it for a set of recipient wallets so only they can decrypt, and share a link. The connected wallet pays for and signs everything.
4
4
 
5
5
  - **Headless-first**: composable hooks for every step (`useMorseFiles`, `useFileUpload`, `useFileDownload`, `useFileDecrypt`, `useRecipientFile`), plus an optional styled component set.
6
6
  - **Recipient-scoped encryption**: each file carries its own recipient set; an encrypted upload to N recipients is **2 wallet popups**, regardless of N. The connected wallet is always a recipient, so the owner can always decrypt. Public (unencrypted) uploads need no recipients.
@@ -16,8 +16,7 @@ npm install @arcadiasystems/morse-uploader
16
16
  Peer dependencies (install in your app, pinned to the same ranges as `@arcadiasystems/morse-sdk` so you do not end up with duplicate Sui/Walrus/Seal instances):
17
17
 
18
18
  ```sh
19
- npm install react react-dom @arcadiasystems/morse-sdk@~0.8.1 \
20
- @mysten/sui@~2.16.2 @mysten/walrus@~1.1.6 @mysten/seal@~1.1.3
19
+ npm install react react-dom @arcadiasystems/morse-sdk@~0.9.0 @mysten/sui@~2.36.0 @mysten/walrus@~1.2.35 @mysten/seal@~1.5.0
21
20
  ```
22
21
 
23
22
  ## Quick start
@@ -61,6 +60,7 @@ function FilesApp({ account }) {
61
60
  uploadRelay: {
62
61
  host: "https://upload-relay.testnet.walrus.space",
63
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".
64
64
  sendTip: { max: 10_000_000 },
65
65
  },
66
66
  },
@@ -114,8 +114,8 @@ All hooks must be used inside `MorseFilesProvider`.
114
114
  | Hook | Purpose |
115
115
  |---|---|
116
116
  | `useMorseFiles(options)` | Build the handles from a connected wallet. Used once, above the provider. |
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. |
118
- | `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. |
119
119
  | `useFileDecrypt()` | Lower-level decrypt: `decrypt({ file, sealIdPrefix, sealNonce })` returns plaintext bytes (one SessionKey signature, reused across files). |
120
120
  | `useRecipientFile(fileId)` | Load a `RecipientFile`'s on-chain metadata (name, contentType, size, members, blobId). |
121
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). |
@@ -124,7 +124,11 @@ All hooks must be used inside `MorseFilesProvider`.
124
124
 
125
125
  ### Rendering an upload error
126
126
 
127
- `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.
128
132
 
129
133
  ## How encryption works
130
134
 
@@ -153,6 +157,7 @@ const parsed = parseShareLink(window.location.href);
153
157
 
154
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.
155
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.
156
161
 
157
162
  ## Seal on mainnet
158
163
 
@@ -191,7 +196,7 @@ credential is not meant to be public.
191
196
 
192
197
  - **Listing a wallet's files needs an indexer (deferred).** A `RecipientFile` is a shared Sui object with no owner-indexable field, so there is no direct "list my files" query. morse-sdk ships pure reconciliation helpers (`reconcileRecipientFilesOwnedBy`, `reconcileRecipientFilesAccessibleBy`) that turn a raw event stream into the current file set, but it deliberately does not fetch events. A production listing requires a Sui event source (a dedicated indexer; `suix_queryEvents` has since been retired by public fullnodes, so an event source now means Sui GraphQL or a dedicated indexer). This package does not ship a listing UI yet for that reason; it is planned once an indexer is in place.
193
198
  - **Seal material is not on-chain.** The Seal identity needed to decrypt (prefix + nonce) is persisted out-of-band (in the share link, here). It is not recoverable from the chain or from an event listing, so a future indexer-backed list can surface encrypted files' metadata but cannot, by itself, decrypt them; decryption still requires the share-link material. Persist `sealIdPrefix`/`sealNonce` in your own store if you need list-and-open UX.
194
- - **dapp-kit needs a JSON-RPC endpoint that still exists.** This package talks gRPC, which the public fullnodes still serve. dapp-kit 1.0.6 talks JSON-RPC only, and Mysten's public fullnodes have retired it: the preflight answers without an `access-control-allow-origin` header, so a browser call fails with `TypeError: Failed to fetch` from `@mysten/sui/jsonRpc` while dapp-kit serializes the transaction, before the wallet opens. Point `SuiClientProvider` at an endpoint that still serves JSON-RPC (your own node, a provider, or a public one such as `https://sui-rpc.publicnode.com`). Nothing in this package changes; the failure is in the wallet layer.
199
+ - **dapp-kit needs a JSON-RPC endpoint that still exists.** This package talks gRPC, which the public fullnodes still serve. dapp-kit 1.1.17, the last `@mysten/dapp-kit` release (Mysten has [deprecated](https://sdk.mystenlabs.com/sui/migrations/sui-2.0/dapp-kit) it for this reason), talks JSON-RPC only, and Mysten's public fullnodes have retired it: the preflight answers without an `access-control-allow-origin` header, so a browser call fails with `TypeError: Failed to fetch` from `@mysten/sui/jsonRpc` while dapp-kit serializes the transaction, before the wallet opens. Point `SuiClientProvider` at an endpoint that still serves JSON-RPC (your own node, a provider, or a public one such as `https://sui-rpc.publicnode.com`). Nothing in this package changes; the failure is in the wallet layer.
195
200
  - **RPC consistency (use a dedicated RPC).** The SDK builds/reads over a gRPC client while the wallet (dapp-kit) executes over its own JSON-RPC client. When those are different nodes, they can sit at different checkpoints, so the certify step occasionally fails with an object-version conflict ("needs to be rebuilt"). For reliable uploads, point both at one **dedicated RPC**: set dapp-kit's `SuiClientProvider` URL and pass a `SuiGrpcClient` on the matching endpoint via `useMorseFiles({ suiClient })`. This is an RPC-infrastructure property, not a bug in the package logic.
196
201
  - **Mainnet works, but encrypted files there need your own Seal key servers.** Testnet pins an open allowlist, so `network: "testnet"` needs no setup. Mainnet pins none, because every mainnet Seal operator is commercial. Without `sealKeyServers`, `handles.seal` is `null`: unencrypted upload and download still work, and the encrypted hooks reject with a message saying so. See [Seal on mainnet](#seal-on-mainnet).
197
202
  - **Storage is a lease.** Walrus storage is capped at 53 epochs ahead and expires; this is not permanent storage.
@@ -1,6 +1,7 @@
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
- import { SessionKey } from '@mysten/seal';
3
+ import { SessionKey, UserError } from '@mysten/seal';
4
+ import { normalizeSuiAddress } from '@mysten/sui/utils';
4
5
 
5
6
  // src/constants/default-encryption-overhead-bytes.ts
6
7
  var DEFAULT_ENCRYPTION_OVERHEAD_BYTES = 150;
@@ -48,6 +49,34 @@ function buildShareLink(args) {
48
49
  return `${base}${path}${query}`;
49
50
  }
50
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
+
51
80
  // src/files/epoch-duration-ms.ts
52
81
  var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
53
82
  var EPOCH_DURATION_MS = {
@@ -72,7 +101,53 @@ function estimateStorageUsd(args) {
72
101
  const rate = args.usdPerGbPerMonth ?? DEFAULT_USD_PER_GB_PER_MONTH;
73
102
  return { usd: gb * months * rate, gb, months, days, sizeBytes };
74
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
+ }
75
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
+ }
76
151
  const shortfall = parseInsufficientBalance(errorText(error));
77
152
  if (shortfall === null) {
78
153
  return formatUserMessage(error);
@@ -188,6 +263,17 @@ function useMorseFilesContext() {
188
263
  }
189
264
  return handles;
190
265
  }
266
+ function assertSignerMatchesAddress(signer, address) {
267
+ const signerAddress = normalizeSuiAddress(signer.toSuiAddress());
268
+ const sessionAddress = normalizeSuiAddress(address);
269
+ if (signerAddress !== sessionAddress) {
270
+ throw new UserError(
271
+ `Signer address does not match session key address: the signer is ${signerAddress}, the session is for ${sessionAddress}. Build the handles with useMorseFiles, or pass a signer for the same account.`
272
+ );
273
+ }
274
+ }
275
+
276
+ // src/files/use-file-decrypt.ts
191
277
  var SEAL_UNAVAILABLE = "No Seal key servers are configured, so decryption is 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.";
192
278
  var DEFAULT_TTL_MIN = 10;
193
279
  function useFileDecrypt() {
@@ -218,6 +304,7 @@ function useFileDecrypt() {
218
304
  if (cached !== null && cached.address === address && cached.packageId === packageId) {
219
305
  return cached.key;
220
306
  }
307
+ assertSignerMatchesAddress(signer, address);
221
308
  const key = await SessionKey.create({
222
309
  address,
223
310
  packageId,
@@ -321,29 +408,65 @@ function useRecipientFile(fileId) {
321
408
  }, [refresh, fileId]);
322
409
  return { status, file, error, refresh };
323
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
324
434
  function useFileDownload(fileId, seal) {
325
- const { walrusRead } = useMorseFilesContext();
435
+ const { reader, walrusRead } = useMorseFilesContext();
326
436
  const { status: metaStatus, file } = useRecipientFile(fileId);
327
437
  const { decrypt } = useFileDecrypt();
328
438
  const [status, setStatus] = useState("idle");
329
439
  const [objectUrl, setObjectUrl] = useState(null);
330
440
  const [error, setError] = useState(null);
331
441
  const urlRef = useRef(null);
442
+ const lifetimeRef = useRef(null);
332
443
  useEffect(() => {
333
444
  if (objectUrl === null) {
334
445
  return;
335
446
  }
336
447
  return () => URL.revokeObjectURL(objectUrl);
337
448
  }, [objectUrl]);
449
+ useEffect(() => {
450
+ const controller = new AbortController();
451
+ lifetimeRef.current = controller;
452
+ return () => controller.abort();
453
+ }, []);
338
454
  const encrypted = seal !== null;
339
455
  const isImage = file === null ? false : isPreviewableImage(file.contentType);
340
456
  const fetchToUrl = useCallback(async () => {
341
457
  if (file === null) {
342
458
  return null;
343
459
  }
460
+ const signal = lifetimeRef.current?.signal;
344
461
  setStatus("loading");
345
462
  setError(null);
346
463
  try {
464
+ const onChainPrefix = await reader.getRecipientFileSealPrefix(
465
+ file.id,
466
+ signal
467
+ );
468
+ signal?.throwIfAborted();
469
+ assertShareLinkOpensFile(onChainPrefix, seal);
347
470
  let bytes;
348
471
  if (seal !== null) {
349
472
  bytes = await decrypt({
@@ -362,11 +485,14 @@ function useFileDownload(fileId, seal) {
362
485
  setStatus("ready");
363
486
  return url;
364
487
  } catch (err) {
488
+ if (signal?.aborted === true) {
489
+ return null;
490
+ }
365
491
  setError(err);
366
492
  setStatus("error");
367
493
  return null;
368
494
  }
369
- }, [file, seal, decrypt, walrusRead]);
495
+ }, [file, seal, reader, decrypt, walrusRead]);
370
496
  const load = useCallback(async () => {
371
497
  await fetchToUrl();
372
498
  }, [fetchToUrl]);
@@ -394,10 +520,145 @@ function useFileDownload(fileId, seal) {
394
520
  download
395
521
  };
396
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
397
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.";
398
659
  var FALLBACK_CONTENT_TYPE = "application/octet-stream";
399
660
  function useFileUpload() {
400
- const { adapter, config, walrusWrite, seal, network } = useMorseFilesContext();
661
+ const { adapter, config, walrusWrite, uploadRelay, seal, network } = useMorseFilesContext();
401
662
  const [status, setStatus] = useState("idle");
402
663
  const [phase, setPhase] = useState(null);
403
664
  const [data, setData] = useState(null);
@@ -415,6 +676,14 @@ function useFileUpload() {
415
676
  setData(null);
416
677
  setError(null);
417
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
+ }
418
687
  const bytes = new Uint8Array(await input.file.arrayBuffer());
419
688
  const contentType = input.file.type || FALLBACK_CONTENT_TYPE;
420
689
  const recipients = input.recipients ?? [];
@@ -432,7 +701,7 @@ function useFileUpload() {
432
701
  adapter,
433
702
  config,
434
703
  {
435
- walrus: walrusWrite,
704
+ walrus: sizeLimit === null ? walrusWrite : withCiphertextSizeLimit(walrusWrite, sizeLimit),
436
705
  seal,
437
706
  plaintext: bytes,
438
707
  recipients,
@@ -483,7 +752,7 @@ function useFileUpload() {
483
752
  throw err;
484
753
  }
485
754
  },
486
- [adapter, config, walrusWrite, seal, network]
755
+ [adapter, config, walrusWrite, uploadRelay, seal, network]
487
756
  );
488
757
  return { status, phase, data, error, upload, reset };
489
758
  }
@@ -615,25 +884,6 @@ function useDropzone(options) {
615
884
  return { isDragging, open, getRootProps, getInputProps, inputRef };
616
885
  }
617
886
 
618
- // src/utils/format-bytes.ts
619
- var UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
620
- var BASE = 1024;
621
- function formatBytes(bytes, fractionDigits = 2) {
622
- if (!Number.isFinite(bytes) || bytes <= 0) {
623
- return "0 B";
624
- }
625
- const exponent = Math.min(
626
- Math.floor(Math.log(bytes) / Math.log(BASE)),
627
- UNITS.length - 1
628
- );
629
- const value = bytes / BASE ** exponent;
630
- const unit = UNITS[exponent] ?? "B";
631
- if (exponent === 0) {
632
- return `${value} ${unit}`;
633
- }
634
- return `${value.toFixed(fractionDigits)} ${unit}`;
635
- }
636
-
637
887
  // src/utils/format-usd.ts
638
888
  var TINY_FLOOR = 1e-6;
639
889
  function formatUsd(usd) {
@@ -652,6 +902,6 @@ function formatUsd(usd) {
652
902
  })}`;
653
903
  }
654
904
 
655
- 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 };
656
- //# sourceMappingURL=chunk-CAUC2ROV.js.map
657
- //# sourceMappingURL=chunk-CAUC2ROV.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