@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 +38 -0
- package/README.md +12 -7
- package/dist/{chunk-CAUC2ROV.js → chunk-ZV3GFXNW.js} +279 -29
- package/dist/chunk-ZV3GFXNW.js.map +1 -0
- package/dist/index.d.ts +57 -14
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/{types-C6w30zCM.d.ts → types-C5hwiHjr.d.ts} +63 -6
- package/dist/ui.d.ts +5 -4
- package/dist/ui.js +2 -3
- package/dist/ui.js.map +1 -1
- package/package.json +9 -9
- package/dist/chunk-CAUC2ROV.js.map +0 -1
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.
|
|
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.
|
|
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
|
|
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.
|
|
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-
|
|
657
|
-
//# sourceMappingURL=chunk-
|
|
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
|