@meddleware/walrus-ui 0.1.18 → 0.1.19

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.19",
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",
@@ -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,13 +11,14 @@ 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
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 { runBlobUpload } from '../upload-flow.js'
20
+ import { NETWORK, relayHosts, accessGate, uploadRelayMaxTipMist, walruscanBlobUrl } from '../config.js'
21
+ import { runBlobUpload, type UploadResumeState } from '../upload-flow.js'
21
22
  import { consumeStorageKey, isRedeemedConflict, resolveGatedAuthToken } from '../access-resume.js'
22
23
  import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
23
24
  import MyBlobs from './MyBlobs.vue'
@@ -51,15 +52,30 @@ async function onPurchase(): Promise<void> {
51
52
  }
52
53
  }
53
54
 
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}`
69
+ }
70
+
54
71
  // 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.
72
+ // 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:
74
+ // 1. Single-use consume: the relay treats the permanent on-chain `consumeDigest` as the one-time
75
+ // redemption token, so a use is only spent when an upload succeeds. The digest is persisted and
76
+ // 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).
63
79
  async function performUpload(
64
80
  bytes: Uint8Array,
65
81
  opts: { relayHost: string; onStatus: (s: string) => void },
@@ -67,21 +83,23 @@ async function performUpload(
67
83
  if (!account.value) throw new Error('Connect your wallet first.')
68
84
  const executor = await buildExecutor()
69
85
  const address = account.value.address
86
+ const key = contentKey(bytes)
70
87
 
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
- }
88
+ const gated = !!(gate && gateState.hasAccess.value === true && gateState.nftId.value)
89
+ const consumeKey = gate ? consumeStorageKey(NETWORK, gate.gateId, address) : null
75
90
 
76
- const nftId = gateState.nftId.value
77
- const key = consumeStorageKey(NETWORK, gate.gateId, address)
78
- const resolve = (forceFresh: boolean) =>
79
- resolveGatedAuthToken({
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
96
+ }
97
+ authTokenRef.value = await resolveGatedAuthToken({
80
98
  storage: window.localStorage,
81
- key,
99
+ key: consumeKey as string,
82
100
  relayHost: opts.relayHost,
83
101
  address,
84
- nftId,
102
+ nftId: gateState.nftId.value as string,
85
103
  fetchChallenge,
86
104
  buildConsume: (id, nonce) => gateState.buildConsume(id, nonce),
87
105
  signAndExecute: (tx) => executor.signAndExecute(tx),
@@ -90,44 +108,59 @@ async function performUpload(
90
108
  sign: signPersonalMessage,
91
109
  forceFresh,
92
110
  })
93
-
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
111
  }
109
- }
110
112
 
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 {
113
+ const deps = (resume?: UploadResumeState) => ({
120
114
  bytes,
121
115
  network: NETWORK,
122
116
  relayHost: opts.relayHost,
123
- address: account.value.address,
117
+ address,
124
118
  wasmUrl: walrusWasmUrl,
125
119
  maxTipMist: uploadRelayMaxTipMist(),
126
120
  epochs: MAX_SINGLE_RESERVATION_EPOCHS,
127
121
  executor,
128
122
  suiClient: getSuiClient(),
129
- authToken,
123
+ // Provider: resolved per request so a resumed flow uses the fresh token.
124
+ authToken: () => authTokenRef.value,
130
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
+ }
137
+
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
+ try {
144
+ return succeed(await runBlobUpload(deps(resumeFor())))
145
+ } 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
+ if (gated && isRedeemedConflict(e)) {
151
+ // 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
+ }
162
+ }
163
+ throw e // keep the retained registration so a manual retry resumes
131
164
  }
132
165
  }
133
166
 
@@ -173,32 +206,65 @@ function onSettled(): void {
173
206
 
174
207
  <WalletGuard message="Connect a Sui wallet to upload and manage your blobs.">
175
208
  <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
- />
183
-
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
- />
192
-
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>
209
+ <!-- Gated + no access: replace the form with the purchase CTA so the user is guided to buy
210
+ first, rather than facing a disabled form. -->
211
+ <div v-if="gateState.gateConfigured && gateState.hasAccess.value === false" class="gate-card">
212
+ <AccessGateCta
213
+ :gate-configured="gateState.gateConfigured"
214
+ :has-access="gateState.hasAccess.value"
215
+ :busy="purchasing"
216
+ :price-mist="gate?.priceMist ?? null"
217
+ @purchase="onPurchase"
218
+ />
219
+ </div>
220
+
221
+ <!-- Ownership check still in flight. -->
222
+ <p
223
+ v-else-if="gateState.gateConfigured && gateState.hasAccess.value === null"
224
+ class="checking"
225
+ >
226
+ Checking access…
227
+ </p>
228
+
229
+ <!-- Access held (or ungated relay): show the upload form. -->
230
+ <template v-else>
231
+ <WalrusUpload
232
+ :hosts="relayHosts(NETWORK)"
233
+ :connected="!!account"
234
+ :access="{ gateConfigured: gateState.gateConfigured, hasAccess: gateState.hasAccess }"
235
+ :perform-upload="performUpload"
236
+ @uploaded="onUploaded"
237
+ @settled="onSettled"
238
+ />
239
+
240
+ <section v-if="result" class="result">
241
+ <h2>Uploaded ✓</h2>
242
+ <p>
243
+ <strong>Blob ID:</strong>
244
+ <CopyableAddress :address="result.blobId" label="Copy blob ID">
245
+ <ExplorerLink
246
+ :href="walruscanBlobUrl(NETWORK, result.blobId)"
247
+ :value="result.blobId"
248
+ :chars="[8, 6]"
249
+ />
250
+ </CopyableAddress>
251
+ </p>
252
+ <p>
253
+ <strong>URL:</strong>
254
+ <a :href="result.url" target="_blank" rel="noopener">{{ result.url }}</a>
255
+ </p>
256
+ <p v-if="result.digest">
257
+ <strong>Certify tx:</strong>
258
+ <CopyableAddress :address="result.digest" label="Copy transaction digest">
259
+ <ExplorerLink
260
+ :href="suiExplorerUrl('txblock', result.digest, NETWORK)"
261
+ :value="result.digest"
262
+ :chars="[8, 6]"
263
+ />
264
+ </CopyableAddress>
265
+ </p>
266
+ </section>
267
+ </template>
202
268
  </template>
203
269
 
204
270
  <MyBlobs
@@ -235,6 +301,21 @@ function onSettled(): void {
235
301
  font-size: 0.85rem;
236
302
  }
237
303
 
304
+ .gate-card {
305
+ margin-top: 1rem;
306
+ padding: 1.5rem;
307
+ border: 1px solid var(--border);
308
+ border-radius: 12px;
309
+ background: var(--surface, transparent);
310
+ text-align: center;
311
+ }
312
+
313
+ .checking {
314
+ margin-top: 1.5rem;
315
+ color: var(--muted);
316
+ text-align: center;
317
+ }
318
+
238
319
  .tabs {
239
320
  display: flex;
240
321
  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,7 +24,7 @@ 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
30
  upload(opts: { digest: string }): Promise<void>
@@ -32,6 +32,17 @@ interface BlobUploadFlow {
32
32
  getBlob(): Promise<{ blobId: string }>
33
33
  }
34
34
 
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
+
35
46
  export interface RunBlobUploadDeps {
36
47
  bytes: Uint8Array
37
48
  network: string
@@ -47,11 +58,24 @@ export interface RunBlobUploadDeps {
47
58
  executor: UploadExecutor
48
59
  /** A Sui client used to `build()` the register/certify transactions. */
49
60
  suiClient: unknown
50
- /** Optional Bearer proof token for an NFT-gated relay. */
51
- authToken?: string
61
+ /**
62
+ * Bearer proof token for an NFT-gated relay. May be a provider resolved per request so a resumed
63
+ * upload presents a fresh challenge signature (see `@meddleware/walrus-client`).
64
+ */
65
+ authToken?: string | (() => string | undefined)
52
66
  onStatus: (s: string) => void
53
67
  /** Lazy loader for the Walrus client module (keeps wasm out of the eager bundle). */
54
68
  loadWalrusClient?: () => Promise<WalrusClientModule>
69
+ /**
70
+ * Resume a prior same-session upload from its registered blob (skips encode + register). Omit for
71
+ * a fresh upload.
72
+ */
73
+ resume?: UploadResumeState
74
+ /**
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.
77
+ */
78
+ onRegistered?: (state: UploadResumeState) => void
55
79
  }
56
80
 
57
81
  /**
@@ -66,27 +90,41 @@ export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResu
66
90
  (async () => (await import('@meddleware/walrus-client')) as unknown as WalrusClientModule)
67
91
  const { createWalrusClient, createBlobUploadFlow, walrusBlobUrl } = await load()
68
92
 
69
- const client = createWalrusClient({
70
- network: deps.network,
71
- wasmUrl: deps.wasmUrl,
72
- uploadRelayHost: deps.relayHost,
73
- uploadRelayAuthToken: deps.authToken,
74
- uploadRelayMaxTipMist: deps.maxTipMist,
75
- })
76
- const flow = createBlobUploadFlow(client, deps.bytes)
93
+ let flow: BlobUploadFlow
94
+ let registerDigest: string
95
+
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)
77
111
 
78
- deps.onStatus('Encoding…')
79
- await flow.encode()
112
+ deps.onStatus('Encoding…')
113
+ await flow.encode()
80
114
 
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)
115
+ deps.onStatus('Registering blob (approve in wallet)…')
116
+ const regTx = flow.register({ owner: deps.address, epochs: deps.epochs, deletable: false })
117
+ regTx.setSenderIfNotSet(deps.address)
118
+ await regTx.build({ client: deps.suiClient })
119
+ const reg = await deps.executor.signAndExecute(regTx)
120
+ await deps.executor.waitForTransaction(reg.digest)
121
+ 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 })
124
+ }
87
125
 
88
126
  deps.onStatus('Uploading to the relay…')
89
- await flow.upload({ digest: reg.digest })
127
+ await flow.upload({ digest: registerDigest })
90
128
 
91
129
  deps.onStatus('Certifying (approve in wallet)…')
92
130
  const certTx = flow.certify()