@meddleware/walrus-ui 0.1.19 → 0.1.22

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.19",
3
+ "version": "0.1.22",
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",
@@ -44,7 +44,7 @@
44
44
  "@meddleware/nft-gate-client": "^0.0.6",
45
45
  "@meddleware/ui": "^0.1.10",
46
46
  "@meddleware/wallet-adapter": "^0.0.5",
47
- "@meddleware/walrus-client": "^0.0.8",
47
+ "@meddleware/walrus-client": "^0.0.9",
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
@@ -11,15 +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, suiExplorerUrl } from '@meddleware/ui'
14
+ import { CopyableAddress, ExplorerLink, UiNotice, suiExplorerUrl } from '@meddleware/ui'
15
15
  // Lightweight URL import — just the wasm asset URL (does not pull the walrus client).
16
16
  import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
17
17
  import { WalletGuard } from '@meddleware/wallet-adapter'
18
18
  import { useWallet, getSuiClient } from '../wallet.js'
19
19
  import { fetchChallenge, buildAccessProof } from '@meddleware/nft-gate-client'
20
20
  import { NETWORK, relayHosts, accessGate, uploadRelayMaxTipMist, walruscanBlobUrl } from '../config.js'
21
- import { runBlobUpload, type UploadResumeState } from '../upload-flow.js'
22
- import { consumeStorageKey, isRedeemedConflict, resolveGatedAuthToken } from '../access-resume.js'
21
+ import { runBlobUpload } from '../upload-flow.js'
22
+ import {
23
+ consumeStorageKey,
24
+ isRedeemedConflict,
25
+ resolveGatedAuthToken,
26
+ registerStorageKey,
27
+ contentKey,
28
+ loadRegisterResume,
29
+ saveRegisterResume,
30
+ clearRegisterResume,
31
+ } from '../access-resume.js'
23
32
  import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
24
33
  import MyBlobs from './MyBlobs.vue'
25
34
 
@@ -52,30 +61,33 @@ async function onPurchase(): Promise<void> {
52
61
  }
53
62
  }
54
63
 
55
- // The relay auth token for the current attempt. The walrus client reads it PER REQUEST (a provider),
56
- // so a retained flow resumed after a failure presents a fresh challenge signature. Undefined
57
- // ungated (no header).
58
- const authTokenRef = ref<string | undefined>(undefined)
59
- // A same-session registered blob retained after a failed upload, keyed by file content, so a retry
60
- // resumes from the relay upload instead of re-registering (no new WAL/gas). Cleared on success or
61
- // when a resumed attempt itself fails (fall back to a fresh full upload).
62
- const uploadSession = ref<{ key: string; state: UploadResumeState } | null>(null)
63
-
64
- /** Cheap content key (length + head/tail bytes) to match a retry to the same selected file. */
65
- function contentKey(bytes: Uint8Array): string {
66
- const head = Array.from(bytes.slice(0, 16)).join(',')
67
- const tail = Array.from(bytes.slice(-16)).join(',')
68
- return `${bytes.length}:${head}:${tail}`
64
+ // On-chain resume discovery: given the encoded blobId, find an already-registered, uncertified blob
65
+ // owned by `owner` and return its register digest, so an interrupted upload can resume WITHOUT a
66
+ // local pointer (robust to cache-clear / new device / incognito). Best-effort: any failure falls
67
+ // through to a fresh register. The walrus client is loaded lazily to keep it out of the eager graph.
68
+ async function discoverRegistration(owner: string, blobId: string): Promise<string | undefined> {
69
+ try {
70
+ const { createWalrusClient, findUncertifiedRegisteredBlob } = await import('@meddleware/walrus-client')
71
+ const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
72
+ const found = await findUncertifiedRegisteredBlob(getSuiClient(), walrusClient, owner, blobId)
73
+ return found?.registerDigest
74
+ } catch {
75
+ return undefined
76
+ }
69
77
  }
70
78
 
71
79
  // Wire the shared WalrusUpload widget to the extracted upload orchestration + the wallet. The
72
80
  // register/upload/certify sequence lives in src/upload-flow.ts (unit-tested); this closure gathers
73
- // the wallet-bound inputs and manages two resume layers so an interrupted upload wastes nothing:
81
+ // the wallet-bound inputs and manages two resume layers so an interrupted upload wastes nothing
82
+ // both persisted in localStorage (fast path) and rediscoverable on-chain (fallback), so they
83
+ // survive a page reload — and even a cache-clear — once the same file is re-selected:
74
84
  // 1. Single-use consume: the relay treats the permanent on-chain `consumeDigest` as the one-time
75
85
  // redemption token, so a use is only spent when an upload succeeds. The digest is persisted and
76
86
  // reused across retries/reload (re-signing a fresh challenge is free); cleared on success.
77
- // 2. Walrus flow (same session): a registered-but-not-uploaded blob is retained and reused on a
78
- // retry of the same file, skipping re-encode/re-register (no new WAL/gas).
87
+ // 2. Walrus register: a registered-but-not-uploaded blob is resumed by its persisted register
88
+ // digest (matched to the re-selected file by content hash), skipping the register transaction
89
+ // (no new WAL/gas). Cleared on success, or when a resumed attempt itself fails (fall back to a
90
+ // fresh register).
79
91
  async function performUpload(
80
92
  bytes: Uint8Array,
81
93
  opts: { relayHost: string; onStatus: (s: string) => void },
@@ -84,83 +96,76 @@ async function performUpload(
84
96
  const executor = await buildExecutor()
85
97
  const address = account.value.address
86
98
  const key = contentKey(bytes)
99
+ const storage = window.localStorage
87
100
 
88
101
  const gated = !!(gate && gateState.hasAccess.value === true && gateState.nftId.value)
89
102
  const consumeKey = gate ? consumeStorageKey(NETWORK, gate.gateId, address) : null
90
-
91
- // Resolve this attempt's relay token (gated only) and stash it where the client reads it.
92
- async function setToken(forceFresh: boolean): Promise<void> {
93
- if (!gated) {
94
- authTokenRef.value = undefined
95
- return
103
+ const regKey = registerStorageKey(NETWORK, address)
104
+
105
+ // Resolve this attempt's relay token (gated only); reuses a stored consume, fresh challenge each time.
106
+ const token = (forceFresh: boolean): Promise<string | undefined> =>
107
+ !gated
108
+ ? Promise.resolve(undefined)
109
+ : resolveGatedAuthToken({
110
+ storage,
111
+ key: consumeKey as string,
112
+ relayHost: opts.relayHost,
113
+ address,
114
+ nftId: gateState.nftId.value as string,
115
+ fetchChallenge,
116
+ buildConsume: (id, nonce) => gateState.buildConsume(id, nonce),
117
+ signAndExecute: (tx) => executor.signAndExecute(tx),
118
+ waitForTransaction: (digest) => executor.waitForTransaction(digest),
119
+ buildAccessProof,
120
+ sign: signPersonalMessage,
121
+ forceFresh,
122
+ })
123
+
124
+ const runOnce = async (authToken: string | undefined): Promise<UploadResult> => {
125
+ // Resume the register step iff a digest was saved for THIS exact file (survives reload).
126
+ const resumeRegisterDigest = loadRegisterResume(storage, regKey, key) ?? undefined
127
+ try {
128
+ const r = await runBlobUpload({
129
+ bytes,
130
+ network: NETWORK,
131
+ relayHost: opts.relayHost,
132
+ address,
133
+ wasmUrl: walrusWasmUrl,
134
+ maxTipMist: uploadRelayMaxTipMist(),
135
+ epochs: MAX_SINGLE_RESERVATION_EPOCHS,
136
+ executor,
137
+ suiClient: getSuiClient(),
138
+ authToken,
139
+ onStatus: opts.onStatus,
140
+ resumeRegisterDigest,
141
+ // On-chain fallback when the local pointer is missing (cache-clear / new device): find an
142
+ // already-registered, uncertified blob for this content and resume from it — gas-free.
143
+ discoverRegisterDigest: (blobId) => discoverRegistration(address, blobId),
144
+ onRegistered: (digest) => saveRegisterResume(storage, regKey, key, digest),
145
+ })
146
+ // Success → clear both resume layers (the use is now genuinely spent for an upload).
147
+ clearRegisterResume(storage, regKey)
148
+ if (consumeKey) storage.removeItem(consumeKey)
149
+ return r
150
+ } catch (e) {
151
+ // A resumed attempt that fails drops the saved register digest so the next try does a full
152
+ // fresh register (never worse than today). A non-resumed failure keeps the digest that
153
+ // onRegistered saved, so the next try (or a reload + re-select) resumes without re-registering.
154
+ if (resumeRegisterDigest !== undefined) clearRegisterResume(storage, regKey)
155
+ throw e
96
156
  }
97
- authTokenRef.value = await resolveGatedAuthToken({
98
- storage: window.localStorage,
99
- key: consumeKey as string,
100
- relayHost: opts.relayHost,
101
- address,
102
- nftId: gateState.nftId.value as string,
103
- fetchChallenge,
104
- buildConsume: (id, nonce) => gateState.buildConsume(id, nonce),
105
- signAndExecute: (tx) => executor.signAndExecute(tx),
106
- waitForTransaction: (digest) => executor.waitForTransaction(digest),
107
- buildAccessProof,
108
- sign: signPersonalMessage,
109
- forceFresh,
110
- })
111
- }
112
-
113
- const deps = (resume?: UploadResumeState) => ({
114
- bytes,
115
- network: NETWORK,
116
- relayHost: opts.relayHost,
117
- address,
118
- wasmUrl: walrusWasmUrl,
119
- maxTipMist: uploadRelayMaxTipMist(),
120
- epochs: MAX_SINGLE_RESERVATION_EPOCHS,
121
- executor,
122
- suiClient: getSuiClient(),
123
- // Provider: resolved per request so a resumed flow uses the fresh token.
124
- authToken: () => authTokenRef.value,
125
- onStatus: opts.onStatus,
126
- resume,
127
- onRegistered: (state: UploadResumeState) => {
128
- uploadSession.value = { key, state }
129
- },
130
- })
131
-
132
- const succeed = (r: UploadResult): UploadResult => {
133
- uploadSession.value = null
134
- if (consumeKey) window.localStorage.removeItem(consumeKey) // use spent only on success
135
- return r
136
157
  }
137
158
 
138
- // Reuse a retained registration for this exact file, if any.
139
- const resumeFor = () => (uploadSession.value?.key === key ? uploadSession.value.state : undefined)
140
- const wasResuming = resumeFor() !== undefined
141
-
142
- await setToken(false)
143
159
  try {
144
- return succeed(await runBlobUpload(deps(resumeFor())))
160
+ return await runOnce(await token(false))
145
161
  } catch (e) {
146
- // A resumed attempt failed → drop the retained registration so the next try does a full fresh
147
- // upload (re-register), guaranteeing behaviour no worse than a non-resumed run.
148
- if (wasResuming) uploadSession.value = null
149
-
150
162
  if (gated && isRedeemedConflict(e)) {
151
163
  // Stored consume already redeemed (a prior upload actually landed): clear it, spend a fresh
152
- // use, and retry — resuming the registered blob if we still hold it.
153
- window.localStorage.removeItem(consumeKey as string)
154
- await setToken(true)
155
- const resume2 = resumeFor()
156
- try {
157
- return succeed(await runBlobUpload(deps(resume2)))
158
- } catch (e2) {
159
- if (resume2) uploadSession.value = null
160
- throw e2
161
- }
164
+ // use, and retry — resuming the registered blob if one is still saved for this file.
165
+ storage.removeItem(consumeKey as string)
166
+ return await runOnce(await token(true))
162
167
  }
163
- throw e // keep the retained registration so a manual retry resumes
168
+ throw e // keep the saved register digest so a manual retry / reload resumes
164
169
  }
165
170
  }
166
171
 
@@ -228,6 +233,17 @@ function onSettled(): void {
228
233
 
229
234
  <!-- Access held (or ungated relay): show the upload form. -->
230
235
  <template v-else>
236
+ <!-- Gated relays spend a credit before the file is stored — make the "attempt, not a
237
+ guarantee" nature explicit, while reassuring that attempts resume. -->
238
+ <UiNotice v-if="gateState.gateConfigured" type="info" class="credit-notice">
239
+ Uploading spends <strong>one credit</strong> from your access NFT (an on-chain step)
240
+ before the file is stored — it pays for an upload <em>attempt</em>, not a guaranteed
241
+ upload. Your attempt resumes automatically, even after a page reload if you re-select
242
+ the same file, so a credit is normally not lost. A credit is spent without a completed
243
+ upload only if you abandon the upload entirely, cancel a required wallet approval, or
244
+ wait long enough that the reserved storage lapses.
245
+ </UiNotice>
246
+
231
247
  <WalrusUpload
232
248
  :hosts="relayHosts(NETWORK)"
233
249
  :connected="!!account"
@@ -316,6 +332,12 @@ function onSettled(): void {
316
332
  text-align: center;
317
333
  }
318
334
 
335
+ .credit-notice {
336
+ margin: 1rem 0;
337
+ font-size: 0.85rem;
338
+ line-height: 1.5;
339
+ }
340
+
319
341
  .tabs {
320
342
  display: flex;
321
343
  gap: 0.25rem;
@@ -25,24 +25,18 @@ interface BuiltTx {
25
25
  }
26
26
 
27
27
  export interface BlobUploadFlow {
28
- encode(): Promise<void>
28
+ // The SDK `encode()` returns a `WriteBlobStepEncoded`; we use its deterministic `blobId` to look
29
+ // up an existing on-chain registration for the same content (resume discovery).
30
+ encode(): Promise<{ blobId: string }>
29
31
  register(opts: { owner: string; epochs: number; deletable: boolean }): BuiltTx
30
- upload(opts: { digest: string }): Promise<void>
32
+ // `digest` = the register transaction digest. When resuming without a prior `register()` call on
33
+ // this flow instance, the SDK accepts the digest + `deletable` to upload against the already
34
+ // registered blob (see `@mysten/walrus` WriteBlobFlowUploadOptions).
35
+ upload(opts: { digest: string; deletable?: boolean }): Promise<void>
31
36
  certify(): BuiltTx
32
37
  getBlob(): Promise<{ blobId: string }>
33
38
  }
34
39
 
35
- /**
36
- * A same-session resume point: a flow whose blob is already registered on-chain, plus the register
37
- * transaction digest. Passing this to {@link runBlobUpload} skips encode + register (no new WAL/gas)
38
- * and retries from the relay upload — so an interrupted upload never re-registers. Not serialisable
39
- * / not for cross-reload use (the flow holds the encoded blob in memory).
40
- */
41
- export interface UploadResumeState {
42
- flow: BlobUploadFlow
43
- registerDigest: string
44
- }
45
-
46
40
  export interface RunBlobUploadDeps {
47
41
  bytes: Uint8Array
48
42
  network: string
@@ -67,20 +61,32 @@ export interface RunBlobUploadDeps {
67
61
  /** Lazy loader for the Walrus client module (keeps wasm out of the eager bundle). */
68
62
  loadWalrusClient?: () => Promise<WalrusClientModule>
69
63
  /**
70
- * Resume a prior same-session upload from its registered blob (skips encode + register). Omit for
71
- * a fresh upload.
64
+ * Resume from a blob registered in a PRIOR attempt (this session or a previous page load) by
65
+ * passing its register transaction digest. The file is re-encoded (client-side, no gas) but the
66
+ * on-chain `register` transaction is skipped — so an interrupted upload never re-registers (no new
67
+ * WAL/gas), even across a reload once the same file is re-selected. Omit for a fresh upload.
72
68
  */
73
- resume?: UploadResumeState
69
+ resumeRegisterDigest?: string
74
70
  /**
75
- * Called once the blob is registered (fresh uploads only), handing back the flow + register digest
76
- * so the caller can retain them and resume the relay upload after a failure without re-registering.
71
+ * On-chain resume fallback: called with the encoded `blobId` when no `resumeRegisterDigest` was
72
+ * supplied. Returns the register digest of an already-registered, uncertified on-chain blob for
73
+ * this content (or `undefined`). This makes resume robust to a lost local pointer (cache-clear /
74
+ * new device) — the registration is discovered on-chain rather than remembered client-side.
77
75
  */
78
- onRegistered?: (state: UploadResumeState) => void
76
+ discoverRegisterDigest?: (blobId: string) => Promise<string | undefined>
77
+ /**
78
+ * Called with the register transaction digest immediately after a fresh register succeeds, so the
79
+ * caller can PERSIST it (e.g. to localStorage) and resume the upload after a failure/reload
80
+ * without re-registering.
81
+ */
82
+ onRegistered?: (registerDigest: string) => void
79
83
  }
80
84
 
81
85
  /**
82
- * Register → upload → certify a blob and resolve its id + public URL. Two wallet approvals
83
- * (register, certify) are requested via `executor`; `onStatus` narrates each step.
86
+ * Register → upload → certify a blob and resolve its id + public URL. Encoding is always performed
87
+ * (client-side, no gas); when `resumeRegisterDigest` is supplied the on-chain register transaction
88
+ * is skipped and the upload proceeds against the already-registered blob. Up to two wallet approvals
89
+ * (register — skipped on resume — and certify) are requested via `executor`; `onStatus` narrates.
84
90
  */
85
91
  export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResult> {
86
92
  // The real module's flow types are richer than the narrow structural subset we use here, so the
@@ -90,28 +96,29 @@ export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResu
90
96
  (async () => (await import('@meddleware/walrus-client')) as unknown as WalrusClientModule)
91
97
  const { createWalrusClient, createBlobUploadFlow, walrusBlobUrl } = await load()
92
98
 
93
- let flow: BlobUploadFlow
94
- let registerDigest: string
99
+ const client = createWalrusClient({
100
+ network: deps.network,
101
+ wasmUrl: deps.wasmUrl,
102
+ uploadRelayHost: deps.relayHost,
103
+ uploadRelayAuthToken: deps.authToken,
104
+ uploadRelayMaxTipMist: deps.maxTipMist,
105
+ })
106
+ const flow = createBlobUploadFlow(client, deps.bytes)
95
107
 
96
- if (deps.resume) {
97
- // Same-session resume: the blob is already registered on-chain. Skip encode + register (no new
98
- // WAL/gas) and retry from the relay upload. The retained flow keeps its registered state; its
99
- // client resolves the relay token per request, so a fresh challenge is used on retry.
100
- flow = deps.resume.flow
101
- registerDigest = deps.resume.registerDigest
102
- } else {
103
- const client = createWalrusClient({
104
- network: deps.network,
105
- wasmUrl: deps.wasmUrl,
106
- uploadRelayHost: deps.relayHost,
107
- uploadRelayAuthToken: deps.authToken,
108
- uploadRelayMaxTipMist: deps.maxTipMist,
109
- })
110
- flow = createBlobUploadFlow(client, deps.bytes)
108
+ // Encoding is deterministic from the content and costs no gas, so it always runs — including on a
109
+ // resume, where it re-derives the slivers for the re-selected file (and yields the blobId used
110
+ // for on-chain resume discovery).
111
+ deps.onStatus('Encoding…')
112
+ const { blobId } = await flow.encode()
111
113
 
112
- deps.onStatus('Encoding…')
113
- await flow.encode()
114
+ // Resolve a resume point: a caller-provided digest (localStorage fast path) or, failing that, an
115
+ // on-chain lookup by blobId (robust to a lost local pointer). Either skips the register tx.
116
+ let registerDigest = deps.resumeRegisterDigest
117
+ if (registerDigest === undefined && deps.discoverRegisterDigest) {
118
+ registerDigest = (await deps.discoverRegisterDigest(blobId)) ?? undefined
119
+ }
114
120
 
121
+ if (registerDigest === undefined) {
115
122
  deps.onStatus('Registering blob (approve in wallet)…')
116
123
  const regTx = flow.register({ owner: deps.address, epochs: deps.epochs, deletable: false })
117
124
  regTx.setSenderIfNotSet(deps.address)
@@ -119,12 +126,12 @@ export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResu
119
126
  const reg = await deps.executor.signAndExecute(regTx)
120
127
  await deps.executor.waitForTransaction(reg.digest)
121
128
  registerDigest = reg.digest
122
- // Hand the registered flow back so the caller can resume the relay upload after a failure.
123
- deps.onRegistered?.({ flow, registerDigest })
129
+ // Persist point: hand back the digest so the upload can be resumed after a failure/reload.
130
+ deps.onRegistered?.(registerDigest)
124
131
  }
125
132
 
126
133
  deps.onStatus('Uploading to the relay…')
127
- await flow.upload({ digest: registerDigest })
134
+ await flow.upload({ digest: registerDigest, deletable: false })
128
135
 
129
136
  deps.onStatus('Certifying (approve in wallet)…')
130
137
  const certTx = flow.certify()