@meddleware/walrus-ui 0.1.18 → 0.1.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meddleware/walrus-ui",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "Standalone Vue 3 SPA for uploading blobs to Walrus decentralised storage and managing owned blobs — wallet-connected, NFT-gated relay support.",
5
5
  "author": "Meddleware <dev@meddleware.co.uk>",
6
6
  "license": "0BSD",
@@ -42,9 +42,9 @@
42
42
  "dependencies": {
43
43
  "@meddleware/design-tokens": "^0.1.2",
44
44
  "@meddleware/nft-gate-client": "^0.0.6",
45
- "@meddleware/ui": "^0.1.9",
45
+ "@meddleware/ui": "^0.1.10",
46
46
  "@meddleware/wallet-adapter": "^0.0.5",
47
- "@meddleware/walrus-client": "^0.0.7",
47
+ "@meddleware/walrus-client": "^0.0.8",
48
48
  "@meddleware/walrus-relay": "^0.1.8",
49
49
  "@mysten/sui": "^2.30.0",
50
50
  "@mysten/wallet-standard": "^0.20.0",
@@ -21,6 +21,55 @@ export function consumeStorageKey(network: string, gateId: string, address: stri
21
21
  return `mw:walrus:consume:${network}:${gateId}:${address}`
22
22
  }
23
23
 
24
+ // ── Register-resume (Walrus flow) ───────────────────────────────────────────────────────────
25
+ // A blob registered on-chain but not yet uploaded/certified can be resumed WITHOUT re-registering
26
+ // (no new WAL/gas): persist its register transaction digest, keyed by a hash of the file content,
27
+ // and reuse it when the same file is uploaded again — even after a page reload, since the user
28
+ // re-selects the file (only the tiny digest is persisted, never the bytes).
29
+
30
+ /** Stable per-(network, address) key under which a pending register digest + content hash is stored. */
31
+ export function registerStorageKey(network: string, address: string): string {
32
+ return `mw:walrus:register:${network}:${address}`
33
+ }
34
+
35
+ /** Cheap content fingerprint (length + head/tail bytes) to match a retry to the same file. */
36
+ export function contentKey(bytes: Uint8Array): string {
37
+ const head = Array.from(bytes.slice(0, 16)).join(',')
38
+ const tail = Array.from(bytes.slice(-16)).join(',')
39
+ return `${bytes.length}:${head}:${tail}`
40
+ }
41
+
42
+ /** Return the stored register digest iff it was saved for this exact file content. */
43
+ export function loadRegisterResume(
44
+ storage: StorageLike,
45
+ key: string,
46
+ content: string,
47
+ ): string | null {
48
+ const raw = storage.getItem(key)
49
+ if (!raw) return null
50
+ try {
51
+ const v = JSON.parse(raw) as { contentKey?: string; registerDigest?: string }
52
+ return v.contentKey === content && v.registerDigest ? v.registerDigest : null
53
+ } catch {
54
+ return null
55
+ }
56
+ }
57
+
58
+ /** Persist a register digest against a file content key so the upload can resume later. */
59
+ export function saveRegisterResume(
60
+ storage: StorageLike,
61
+ key: string,
62
+ content: string,
63
+ registerDigest: string,
64
+ ): void {
65
+ storage.setItem(key, JSON.stringify({ contentKey: content, registerDigest }))
66
+ }
67
+
68
+ /** Clear any stored register-resume entry (on success, or to fall back to a fresh register). */
69
+ export function clearRegisterResume(storage: StorageLike, key: string): void {
70
+ storage.removeItem(key)
71
+ }
72
+
24
73
  /**
25
74
  * True if `err` is the gateway's "this consume was already redeemed" rejection (HTTP 409 with
26
75
  * `code: 'redeemed'`). Distinguished from transient failures so we only re-consume (spend a new
@@ -1,9 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import { onMounted, ref, watch } from 'vue'
3
+ import { CopyableAddress, ExplorerLink } from '@meddleware/ui'
3
4
  import type { OwnedBlob } from '@meddleware/walrus-client'
4
5
  import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
5
6
  import type { Executor } from '../wallet.js'
6
- import { NETWORK } from '../config.js'
7
+ import { NETWORK, walruscanBlobUrl } from '../config.js'
7
8
  import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
8
9
 
9
10
  const props = defineProps<{
@@ -92,7 +93,15 @@ watch(() => props.address, (addr) => void load(addr))
92
93
  :key="blob.objectId"
93
94
  :class="{ warn: blob.endEpoch - currentEpoch < EXPIRY_WARN_EPOCHS }"
94
95
  >
95
- <td class="mono">{{ blob.blobId.slice(0, 12) }}…</td>
96
+ <td>
97
+ <CopyableAddress :address="blob.blobId" label="Copy blob ID">
98
+ <ExplorerLink
99
+ :href="walruscanBlobUrl(NETWORK, blob.blobId)"
100
+ :value="blob.blobId"
101
+ :chars="[8, 6]"
102
+ />
103
+ </CopyableAddress>
104
+ </td>
96
105
  <td>{{ (blob.size / 1024).toFixed(1) }} KB</td>
97
106
  <td>
98
107
  epoch {{ blob.endEpoch }}
@@ -11,14 +11,24 @@ import {
11
11
  MAX_SINGLE_RESERVATION_EPOCHS,
12
12
  } from '@meddleware/walrus-relay'
13
13
  import type { UploadResult } from '@meddleware/walrus-relay'
14
+ import { CopyableAddress, ExplorerLink, UiNotice, suiExplorerUrl } from '@meddleware/ui'
14
15
  // Lightweight URL import — just the wasm asset URL (does not pull the walrus client).
15
16
  import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
16
17
  import { WalletGuard } from '@meddleware/wallet-adapter'
17
18
  import { useWallet, getSuiClient } from '../wallet.js'
18
19
  import { fetchChallenge, buildAccessProof } from '@meddleware/nft-gate-client'
19
- import { NETWORK, relayHosts, accessGate, uploadRelayMaxTipMist } from '../config.js'
20
+ import { NETWORK, relayHosts, accessGate, uploadRelayMaxTipMist, walruscanBlobUrl } from '../config.js'
20
21
  import { runBlobUpload } from '../upload-flow.js'
21
- import { consumeStorageKey, isRedeemedConflict, resolveGatedAuthToken } from '../access-resume.js'
22
+ import {
23
+ consumeStorageKey,
24
+ isRedeemedConflict,
25
+ resolveGatedAuthToken,
26
+ registerStorageKey,
27
+ contentKey,
28
+ loadRegisterResume,
29
+ saveRegisterResume,
30
+ clearRegisterResume,
31
+ } from '../access-resume.js'
22
32
  import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
23
33
  import MyBlobs from './MyBlobs.vue'
24
34
 
@@ -52,14 +62,16 @@ async function onPurchase(): Promise<void> {
52
62
  }
53
63
 
54
64
  // Wire the shared WalrusUpload widget to the extracted upload orchestration + the wallet. The
55
- // register/upload/certify sequence lives in src/upload-flow.ts (unit-tested); this closure only
56
- // gathers the wallet-bound inputs (executor, sui client, gated proof token) and delegates.
57
- //
58
- // Single-use resume: the relay treats the permanent on-chain `consumeDigest` as the one-time
59
- // redemption token, so a use is only spent when an upload succeeds. We persist the digest before
60
- // uploading and reuse it on retry/reload (re-signing a fresh challenge is free) an interrupted
61
- // upload never burns a use. On success we clear it; if the relay reports the digest already
62
- // redeemed (a prior upload actually landed), we clear and consume a fresh use once.
65
+ // register/upload/certify sequence lives in src/upload-flow.ts (unit-tested); this closure gathers
66
+ // the wallet-bound inputs and manages two resume layers so an interrupted upload wastes nothing —
67
+ // both persisted in localStorage, so they survive a page reload once the same file is re-selected:
68
+ // 1. Single-use consume: the relay treats the permanent on-chain `consumeDigest` as the one-time
69
+ // redemption token, so a use is only spent when an upload succeeds. The digest is persisted and
70
+ // reused across retries/reload (re-signing a fresh challenge is free); cleared on success.
71
+ // 2. Walrus register: a registered-but-not-uploaded blob is resumed by its persisted register
72
+ // digest (matched to the re-selected file by content hash), skipping the register transaction
73
+ // (no new WAL/gas). Cleared on success, or when a resumed attempt itself fails (fall back to a
74
+ // fresh register).
63
75
  async function performUpload(
64
76
  bytes: Uint8Array,
65
77
  opts: { relayHost: string; onStatus: (s: string) => void },
@@ -67,67 +79,74 @@ async function performUpload(
67
79
  if (!account.value) throw new Error('Connect your wallet first.')
68
80
  const executor = await buildExecutor()
69
81
  const address = account.value.address
82
+ const key = contentKey(bytes)
83
+ const storage = window.localStorage
70
84
 
71
- // Ungated relay: no consume, straight upload.
72
- if (!(gate && gateState.hasAccess.value === true && gateState.nftId.value)) {
73
- return runBlobUpload(uploadDeps(bytes, opts, executor, undefined))
74
- }
85
+ const gated = !!(gate && gateState.hasAccess.value === true && gateState.nftId.value)
86
+ const consumeKey = gate ? consumeStorageKey(NETWORK, gate.gateId, address) : null
87
+ const regKey = registerStorageKey(NETWORK, address)
75
88
 
76
- const nftId = gateState.nftId.value
77
- const key = consumeStorageKey(NETWORK, gate.gateId, address)
78
- const resolve = (forceFresh: boolean) =>
79
- resolveGatedAuthToken({
80
- storage: window.localStorage,
81
- key,
82
- relayHost: opts.relayHost,
83
- address,
84
- nftId,
85
- fetchChallenge,
86
- buildConsume: (id, nonce) => gateState.buildConsume(id, nonce),
87
- signAndExecute: (tx) => executor.signAndExecute(tx),
88
- waitForTransaction: (digest) => executor.waitForTransaction(digest),
89
- buildAccessProof,
90
- sign: signPersonalMessage,
91
- forceFresh,
92
- })
89
+ // Resolve this attempt's relay token (gated only); reuses a stored consume, fresh challenge each time.
90
+ const token = (forceFresh: boolean): Promise<string | undefined> =>
91
+ !gated
92
+ ? Promise.resolve(undefined)
93
+ : resolveGatedAuthToken({
94
+ storage,
95
+ key: consumeKey as string,
96
+ relayHost: opts.relayHost,
97
+ address,
98
+ nftId: gateState.nftId.value as string,
99
+ fetchChallenge,
100
+ buildConsume: (id, nonce) => gateState.buildConsume(id, nonce),
101
+ signAndExecute: (tx) => executor.signAndExecute(tx),
102
+ waitForTransaction: (digest) => executor.waitForTransaction(digest),
103
+ buildAccessProof,
104
+ sign: signPersonalMessage,
105
+ forceFresh,
106
+ })
93
107
 
94
- try {
95
- const authToken = await resolve(false)
96
- const result = await runBlobUpload(uploadDeps(bytes, opts, executor, authToken))
97
- window.localStorage.removeItem(key) // upload succeeded → the use is now spent
98
- return result
99
- } catch (e) {
100
- if (!isRedeemedConflict(e)) throw e // transient failure → keep the digest so the next try resumes
101
- // The stored consume was already redeemed (a prior upload actually landed). Clear it and spend
102
- // one fresh use for this new upload.
103
- window.localStorage.removeItem(key)
104
- const authToken = await resolve(true)
105
- const result = await runBlobUpload(uploadDeps(bytes, opts, executor, authToken))
106
- window.localStorage.removeItem(key)
107
- return result
108
+ const runOnce = async (authToken: string | undefined): Promise<UploadResult> => {
109
+ // Resume the register step iff a digest was saved for THIS exact file (survives reload).
110
+ const resumeRegisterDigest = loadRegisterResume(storage, regKey, key) ?? undefined
111
+ try {
112
+ const r = await runBlobUpload({
113
+ bytes,
114
+ network: NETWORK,
115
+ relayHost: opts.relayHost,
116
+ address,
117
+ wasmUrl: walrusWasmUrl,
118
+ maxTipMist: uploadRelayMaxTipMist(),
119
+ epochs: MAX_SINGLE_RESERVATION_EPOCHS,
120
+ executor,
121
+ suiClient: getSuiClient(),
122
+ authToken,
123
+ onStatus: opts.onStatus,
124
+ resumeRegisterDigest,
125
+ onRegistered: (digest) => saveRegisterResume(storage, regKey, key, digest),
126
+ })
127
+ // Success → clear both resume layers (the use is now genuinely spent for an upload).
128
+ clearRegisterResume(storage, regKey)
129
+ if (consumeKey) storage.removeItem(consumeKey)
130
+ return r
131
+ } catch (e) {
132
+ // A resumed attempt that fails drops the saved register digest so the next try does a full
133
+ // fresh register (never worse than today). A non-resumed failure keeps the digest that
134
+ // onRegistered saved, so the next try (or a reload + re-select) resumes without re-registering.
135
+ if (resumeRegisterDigest !== undefined) clearRegisterResume(storage, regKey)
136
+ throw e
137
+ }
108
138
  }
109
- }
110
139
 
111
- /** Assemble the register→upload→certify inputs for {@link runBlobUpload}. */
112
- function uploadDeps(
113
- bytes: Uint8Array,
114
- opts: { relayHost: string; onStatus: (s: string) => void },
115
- executor: Awaited<ReturnType<typeof buildExecutor>>,
116
- authToken: string | undefined,
117
- ) {
118
- if (!account.value) throw new Error('Connect your wallet first.')
119
- return {
120
- bytes,
121
- network: NETWORK,
122
- relayHost: opts.relayHost,
123
- address: account.value.address,
124
- wasmUrl: walrusWasmUrl,
125
- maxTipMist: uploadRelayMaxTipMist(),
126
- epochs: MAX_SINGLE_RESERVATION_EPOCHS,
127
- executor,
128
- suiClient: getSuiClient(),
129
- authToken,
130
- onStatus: opts.onStatus,
140
+ try {
141
+ return await runOnce(await token(false))
142
+ } catch (e) {
143
+ if (gated && isRedeemedConflict(e)) {
144
+ // Stored consume already redeemed (a prior upload actually landed): clear it, spend a fresh
145
+ // use, and retry — resuming the registered blob if one is still saved for this file.
146
+ storage.removeItem(consumeKey as string)
147
+ return await runOnce(await token(true))
148
+ }
149
+ throw e // keep the saved register digest so a manual retry / reload resumes
131
150
  }
132
151
  }
133
152
 
@@ -173,32 +192,76 @@ function onSettled(): void {
173
192
 
174
193
  <WalletGuard message="Connect a Sui wallet to upload and manage your blobs.">
175
194
  <template v-if="activeTab === 'upload'">
176
- <AccessGateCta
177
- :gate-configured="gateState.gateConfigured"
178
- :has-access="gateState.hasAccess.value"
179
- :busy="purchasing"
180
- :price-mist="gate?.priceMist ?? null"
181
- @purchase="onPurchase"
182
- />
195
+ <!-- Gated + no access: replace the form with the purchase CTA so the user is guided to buy
196
+ first, rather than facing a disabled form. -->
197
+ <div v-if="gateState.gateConfigured && gateState.hasAccess.value === false" class="gate-card">
198
+ <AccessGateCta
199
+ :gate-configured="gateState.gateConfigured"
200
+ :has-access="gateState.hasAccess.value"
201
+ :busy="purchasing"
202
+ :price-mist="gate?.priceMist ?? null"
203
+ @purchase="onPurchase"
204
+ />
205
+ </div>
206
+
207
+ <!-- Ownership check still in flight. -->
208
+ <p
209
+ v-else-if="gateState.gateConfigured && gateState.hasAccess.value === null"
210
+ class="checking"
211
+ >
212
+ Checking access…
213
+ </p>
183
214
 
184
- <WalrusUpload
185
- :hosts="relayHosts(NETWORK)"
186
- :connected="!!account"
187
- :access="{ gateConfigured: gateState.gateConfigured, hasAccess: gateState.hasAccess }"
188
- :perform-upload="performUpload"
189
- @uploaded="onUploaded"
190
- @settled="onSettled"
191
- />
215
+ <!-- Access held (or ungated relay): show the upload form. -->
216
+ <template v-else>
217
+ <!-- Gated relays spend a use before the file is stored — make the "attempt, not a
218
+ guarantee" nature explicit, while reassuring that attempts resume. -->
219
+ <UiNotice v-if="gateState.gateConfigured" type="info" class="use-notice">
220
+ Uploading spends <strong>one use</strong> of your access NFT (an on-chain step) before
221
+ the file is stored — it pays for an upload <em>attempt</em>, not a guaranteed upload.
222
+ Your attempt resumes automatically, even after a page reload if you re-select the same
223
+ file, so a use is normally not lost. A use is spent without a completed upload only if
224
+ you abandon the upload entirely, cancel a required wallet approval, or wait long enough
225
+ that the reserved storage lapses.
226
+ </UiNotice>
192
227
 
193
- <section v-if="result" class="result">
194
- <h2>Uploaded ✓</h2>
195
- <p><strong>Blob ID:</strong> <code>{{ result.blobId }}</code></p>
196
- <p>
197
- <strong>URL:</strong>
198
- <a :href="result.url" target="_blank" rel="noopener">{{ result.url }}</a>
199
- </p>
200
- <p v-if="result.digest"><strong>Certify tx:</strong> <code>{{ result.digest }}</code></p>
201
- </section>
228
+ <WalrusUpload
229
+ :hosts="relayHosts(NETWORK)"
230
+ :connected="!!account"
231
+ :access="{ gateConfigured: gateState.gateConfigured, hasAccess: gateState.hasAccess }"
232
+ :perform-upload="performUpload"
233
+ @uploaded="onUploaded"
234
+ @settled="onSettled"
235
+ />
236
+
237
+ <section v-if="result" class="result">
238
+ <h2>Uploaded ✓</h2>
239
+ <p>
240
+ <strong>Blob ID:</strong>
241
+ <CopyableAddress :address="result.blobId" label="Copy blob ID">
242
+ <ExplorerLink
243
+ :href="walruscanBlobUrl(NETWORK, result.blobId)"
244
+ :value="result.blobId"
245
+ :chars="[8, 6]"
246
+ />
247
+ </CopyableAddress>
248
+ </p>
249
+ <p>
250
+ <strong>URL:</strong>
251
+ <a :href="result.url" target="_blank" rel="noopener">{{ result.url }}</a>
252
+ </p>
253
+ <p v-if="result.digest">
254
+ <strong>Certify tx:</strong>
255
+ <CopyableAddress :address="result.digest" label="Copy transaction digest">
256
+ <ExplorerLink
257
+ :href="suiExplorerUrl('txblock', result.digest, NETWORK)"
258
+ :value="result.digest"
259
+ :chars="[8, 6]"
260
+ />
261
+ </CopyableAddress>
262
+ </p>
263
+ </section>
264
+ </template>
202
265
  </template>
203
266
 
204
267
  <MyBlobs
@@ -235,6 +298,27 @@ function onSettled(): void {
235
298
  font-size: 0.85rem;
236
299
  }
237
300
 
301
+ .gate-card {
302
+ margin-top: 1rem;
303
+ padding: 1.5rem;
304
+ border: 1px solid var(--border);
305
+ border-radius: 12px;
306
+ background: var(--surface, transparent);
307
+ text-align: center;
308
+ }
309
+
310
+ .checking {
311
+ margin-top: 1.5rem;
312
+ color: var(--muted);
313
+ text-align: center;
314
+ }
315
+
316
+ .use-notice {
317
+ margin: 1rem 0;
318
+ font-size: 0.85rem;
319
+ line-height: 1.5;
320
+ }
321
+
238
322
  .tabs {
239
323
  display: flex;
240
324
  gap: 0.25rem;
@@ -34,11 +34,14 @@ async function load(address: string | null, opts: { force?: boolean } = {}): Pro
34
34
  const { createWalrusClient, fetchOwnedWalrusBlobs } = await import('@meddleware/walrus-client')
35
35
  const suiClient = getSuiClient()
36
36
  const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
37
+ // Blob `endEpoch` is a WALRUS epoch, so compare against the Walrus committee epoch — NOT the
38
+ // Sui system-state epoch (they are different clocks; the Sui epoch, ~1218 vs ~570, made every
39
+ // blob read as "expired").
37
40
  const [sys, fetched] = await Promise.all([
38
- suiClient.getCurrentSystemState(),
41
+ walrusClient.walrus.systemState(),
39
42
  fetchOwnedWalrusBlobs(suiClient, walrusClient, address),
40
43
  ])
41
- currentEpoch.value = Number(sys.systemState.epoch)
44
+ currentEpoch.value = Number(sys.committee.epoch)
42
45
  blobs.value = fetched.sort((a: OwnedBlob, b: OwnedBlob) => a.endEpoch - b.endEpoch)
43
46
  loadedFor.value = address
44
47
  } catch (e) {
package/src/config.ts CHANGED
@@ -34,6 +34,14 @@ export function relayHosts(network: WalrusNetwork): { operator: string; public:
34
34
  return { operator: OPERATOR_RELAY_HOSTS[network], public: PUBLIC_WALRUS_RELAY_HOSTS[network] }
35
35
  }
36
36
 
37
+ /**
38
+ * Walruscan explorer URL for a blob. A Walrus blob id is not a Sui object, so it links to the
39
+ * Walrus-native explorer rather than a Sui explorer (SuiVision handles Sui entities elsewhere).
40
+ */
41
+ export function walruscanBlobUrl(network: WalrusNetwork, blobId: string): string {
42
+ return `https://walruscan.com/${network}/blob/${blobId}`
43
+ }
44
+
37
45
  /** Default relay tip ceiling (MIST) when `VITE_UPLOAD_RELAY_MAX_TIP_MIST` is unset (0.5 SUI). */
38
46
  export const DEFAULT_UPLOAD_RELAY_MAX_TIP_MIST = 500_000_000
39
47
 
@@ -24,10 +24,13 @@ interface BuiltTx {
24
24
  build(opts: { client: unknown }): Promise<unknown>
25
25
  }
26
26
 
27
- interface BlobUploadFlow {
27
+ export interface BlobUploadFlow {
28
28
  encode(): Promise<void>
29
29
  register(opts: { owner: string; epochs: number; deletable: boolean }): BuiltTx
30
- upload(opts: { digest: string }): Promise<void>
30
+ // `digest` = the register transaction digest. When resuming without a prior `register()` call on
31
+ // this flow instance, the SDK accepts the digest + `deletable` to upload against the already
32
+ // registered blob (see `@mysten/walrus` WriteBlobFlowUploadOptions).
33
+ upload(opts: { digest: string; deletable?: boolean }): Promise<void>
31
34
  certify(): BuiltTx
32
35
  getBlob(): Promise<{ blobId: string }>
33
36
  }
@@ -47,16 +50,34 @@ export interface RunBlobUploadDeps {
47
50
  executor: UploadExecutor
48
51
  /** A Sui client used to `build()` the register/certify transactions. */
49
52
  suiClient: unknown
50
- /** Optional Bearer proof token for an NFT-gated relay. */
51
- authToken?: string
53
+ /**
54
+ * Bearer proof token for an NFT-gated relay. May be a provider resolved per request so a resumed
55
+ * upload presents a fresh challenge signature (see `@meddleware/walrus-client`).
56
+ */
57
+ authToken?: string | (() => string | undefined)
52
58
  onStatus: (s: string) => void
53
59
  /** Lazy loader for the Walrus client module (keeps wasm out of the eager bundle). */
54
60
  loadWalrusClient?: () => Promise<WalrusClientModule>
61
+ /**
62
+ * Resume from a blob registered in a PRIOR attempt (this session or a previous page load) by
63
+ * passing its register transaction digest. The file is re-encoded (client-side, no gas) but the
64
+ * on-chain `register` transaction is skipped — so an interrupted upload never re-registers (no new
65
+ * WAL/gas), even across a reload once the same file is re-selected. Omit for a fresh upload.
66
+ */
67
+ resumeRegisterDigest?: string
68
+ /**
69
+ * Called with the register transaction digest immediately after a fresh register succeeds, so the
70
+ * caller can PERSIST it (e.g. to localStorage) and resume the upload after a failure/reload
71
+ * without re-registering.
72
+ */
73
+ onRegistered?: (registerDigest: string) => void
55
74
  }
56
75
 
57
76
  /**
58
- * Register → upload → certify a blob and resolve its id + public URL. Two wallet approvals
59
- * (register, certify) are requested via `executor`; `onStatus` narrates each step.
77
+ * Register → upload → certify a blob and resolve its id + public URL. Encoding is always performed
78
+ * (client-side, no gas); when `resumeRegisterDigest` is supplied the on-chain register transaction
79
+ * is skipped and the upload proceeds against the already-registered blob. Up to two wallet approvals
80
+ * (register — skipped on resume — and certify) are requested via `executor`; `onStatus` narrates.
60
81
  */
61
82
  export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResult> {
62
83
  // The real module's flow types are richer than the narrow structural subset we use here, so the
@@ -75,18 +96,30 @@ export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResu
75
96
  })
76
97
  const flow = createBlobUploadFlow(client, deps.bytes)
77
98
 
99
+ // Encoding is deterministic from the content and costs no gas, so it always runs — including on a
100
+ // resume, where it re-derives the slivers for the re-selected file.
78
101
  deps.onStatus('Encoding…')
79
102
  await flow.encode()
80
103
 
81
- deps.onStatus('Registering blob (approve in wallet)…')
82
- const regTx = flow.register({ owner: deps.address, epochs: deps.epochs, deletable: false })
83
- regTx.setSenderIfNotSet(deps.address)
84
- await regTx.build({ client: deps.suiClient })
85
- const reg = await deps.executor.signAndExecute(regTx)
86
- await deps.executor.waitForTransaction(reg.digest)
104
+ let registerDigest: string
105
+ if (deps.resumeRegisterDigest) {
106
+ // Resume: the blob was registered in a prior attempt. Skip the register transaction (no new
107
+ // WAL/gas) and upload against the existing on-chain registration.
108
+ registerDigest = deps.resumeRegisterDigest
109
+ } else {
110
+ deps.onStatus('Registering blob (approve in wallet)…')
111
+ const regTx = flow.register({ owner: deps.address, epochs: deps.epochs, deletable: false })
112
+ regTx.setSenderIfNotSet(deps.address)
113
+ await regTx.build({ client: deps.suiClient })
114
+ const reg = await deps.executor.signAndExecute(regTx)
115
+ await deps.executor.waitForTransaction(reg.digest)
116
+ registerDigest = reg.digest
117
+ // Persist point: hand back the digest so the upload can be resumed after a failure/reload.
118
+ deps.onRegistered?.(registerDigest)
119
+ }
87
120
 
88
121
  deps.onStatus('Uploading to the relay…')
89
- await flow.upload({ digest: reg.digest })
122
+ await flow.upload({ digest: registerDigest, deletable: false })
90
123
 
91
124
  deps.onStatus('Certifying (approve in wallet)…')
92
125
  const certTx = flow.certify()