@meddleware/walrus-storage-ui 0.1.65 → 0.1.67

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.
@@ -4,37 +4,16 @@
4
4
  // from the shared @meddleware/wallet-adapter singleton (via ./wallet.js), so connecting here or
5
5
  // in any other inline tool view reflects everywhere.
6
6
  import { computed, ref, watch } from 'vue'
7
- import { normalizeSuiAddress } from '@mysten/sui/utils'
8
- import {
9
- WalrusUpload,
10
- AccessGateCta,
11
- useAccessGate,
12
- type WalrusNetwork,
13
- } from '@meddleware/walrus-storage-kit'
14
- import { AppTabNav, CopyableAddress, ExplorerLink, UiNotice, UiTabPanel, UiToolIntro, suiExplorerUrl, safeHref, type AppTab } from '@meddleware/ui'
15
- // Lightweight URL import — just the wasm asset URL (does not pull the walrus client).
16
- import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
7
+ import { AppTabNav, UiButton, UiLiveStatus, UiNotice, UiTabPanel, UiToolIntro, type AppTab } from '@meddleware/ui'
17
8
  import { WalletGuard } from '@meddleware/wallet-adapter'
18
- import { useWallet, getSuiClient } from '../wallet.js'
19
- import { network, walrusNetwork, relayHosts, accessGate, uploadRelayMaxTipMist, walruscanBlobUrl } from '../config.js'
20
- import {
21
- runBlobUpload,
22
- createGatedAccess,
23
- consumeStorageKey,
24
- pendingCertifyKey,
25
- savePendingCertify,
26
- clearPendingCertify,
27
- loadPendingCertifies,
28
- type BlobUploadResult as UploadResult,
29
- type ExistingCopy,
30
- type UploadProgress,
31
- browserStorage,
32
- } from '@meddleware/walrus-storage-client/flow'
33
-
34
- // Browser storage that never throws (blocked site data falls back to memory for this page).
35
- const storage = browserStorage()
9
+ import { AccessGateCta, WalrusUpload, useAccessGate, type WalrusNetwork } from '@meddleware/walrus-storage-kit'
10
+ import type { ExistingCopy } from '@meddleware/walrus-storage-client/flow'
11
+ import { buildExecutor, getSuiClient, useWallet } from '../wallet.js'
12
+ import { accessGate, network, relayHosts, walrusNetwork } from '../config.js'
36
13
  import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
14
+ import { useStorageUpload, type UploadResult } from '../composables/useStorageUpload.js'
37
15
  import MyBlobs from './MyBlobs.vue'
16
+ import UploadResultCard from './UploadResultCard.vue'
38
17
 
39
18
  const TABS: AppTab[] = [
40
19
  { id: 'upload', label: 'Upload' },
@@ -42,29 +21,33 @@ const TABS: AppTab[] = [
42
21
  ]
43
22
  const activeTab = ref<string>('upload')
44
23
 
45
- const { account, signPersonalMessage, buildExecutor } = useWallet()
24
+ const { account } = useWallet()
46
25
 
47
- // One access-gate state per Walrus network (the gate is a per-network setting); the active
48
- // network's is used. Each is inert until an ownership check runs.
49
- // `useAccessGate` throws for a gate that is not the published deployment on its network;
50
- // `accessGate()` only returns such configs (or null, ungated). The shared client serves the active
51
- // network only, so a state for another network refuses to read rather than reading the wrong chain.
52
- function gateClient(net: WalrusNetwork) {
53
- if (net !== network.value) throw new Error(`No Sui client for ${net} while ${network.value} is active.`)
54
- return getSuiClient()
55
- }
56
- const gateStates = {
57
- testnet: useAccessGate({ network: 'testnet', gate: accessGate('testnet'), getClient: gateClient }),
58
- mainnet: useAccessGate({ network: 'mainnet', gate: accessGate('mainnet'), getClient: gateClient }),
26
+ // One access-gate state per Walrus network (the gate is a per-network setting), created when its network is first the active one and
27
+ // inert until an ownership check runs. `useAccessGate` throws for a gate that is not the published deployment on its network;
28
+ // `accessGate()` only returns such configs (or null, ungated). The shared client serves the active network only, so a state for
29
+ // another network refuses to read rather than reading the wrong chain.
30
+ const gateStates: Partial<Record<WalrusNetwork, ReturnType<typeof useAccessGate>>> = {}
31
+ function gateFor(net: WalrusNetwork) {
32
+ return (gateStates[net] ??= useAccessGate({
33
+ network: net,
34
+ gate: accessGate(net),
35
+ getClient: () => {
36
+ if (net !== network.value) throw new Error(`No Sui client for ${net} while ${network.value} is active.`)
37
+ return getSuiClient()
38
+ },
39
+ }))
59
40
  }
60
41
  /** The active network's gate state (testnet's while the view is hidden on a non-Walrus network). */
61
- const gateState = computed(() => gateStates[walrusNetwork.value ?? 'testnet'])
42
+ const gateState = computed(() => gateFor(walrusNetwork.value ?? 'testnet'))
62
43
  const purchasing = ref(false)
63
44
  const result = ref<UploadResult | null>(null)
45
+ /** A gate is configured and the ownership check has not decided yet. */
46
+ const checkingAccess = computed(() => gateState.value.gateConfigured && gateState.value.hasAccess.value === null)
47
+ const needsPass = computed(() => gateState.value.gateConfigured && gateState.value.hasAccess.value === false)
64
48
 
65
- // Check gate ownership whenever the connected account or the network changes. A switched or
66
- // disconnected wallet never inherits the previous wallet's access state: `reset()` also discards
67
- // any in-flight check.
49
+ // Check gate ownership whenever the connected account or the network changes. A switched or disconnected wallet never inherits the
50
+ // previous wallet's access state: `reset()` also discards any in-flight check.
68
51
  watch(
69
52
  [() => account.value?.address ?? null, walrusNetwork],
70
53
  ([addr], previous) => {
@@ -81,8 +64,7 @@ async function onPurchase(): Promise<void> {
81
64
  if (!account.value) return
82
65
  purchasing.value = true
83
66
  try {
84
- const executor = await buildExecutor()
85
- await gateState.value.purchase(executor, account.value.address)
67
+ await gateState.value.purchase(await buildExecutor(), account.value.address)
86
68
  } catch {
87
69
  // The reason is published in `purchaseError` and shown by the prompt.
88
70
  } finally {
@@ -90,117 +72,8 @@ async function onPurchase(): Promise<void> {
90
72
  }
91
73
  }
92
74
 
93
- // Wire the shared WalrusUpload widget to walrus-storage-client's upload orchestrator and the wallet. The
94
- // orchestrator registers fresh on every attempt (the relay rejects an old register transaction),
95
- // and, for a gated relay, `createGatedAccess` keeps the single-use consume resumable: its digest is
96
- // persisted before the upload, reused after an interruption, cleared once the upload lands, and
97
- // replaced only after the gateway reports it redeemed.
98
- async function performUpload(
99
- bytes: Uint8Array,
100
- opts: {
101
- relayHost: string
102
- epochs: number
103
- force?: boolean
104
- deletable?: boolean
105
- onStatus: (s: string | UploadProgress) => void
106
- },
107
- ): Promise<UploadResult> {
108
- if (!account.value) throw new Error('Connect your wallet first.')
109
- const net = walrusNetwork.value
110
- if (!net) throw new Error(`Walrus storage is not available on ${network.value}.`)
111
- const executor = await buildExecutor()
112
- const address = account.value.address
113
- const state = gateState.value
114
- const gate = state.gate
115
- const nftId = state.nftId.value
116
-
117
- const access =
118
- gate && state.hasAccess.value === true && nftId
119
- ? createGatedAccess({
120
- storage,
121
- key: consumeStorageKey(net, gate.gateId, address),
122
- relayHost: opts.relayHost,
123
- address,
124
- // Signed into every access proof with the relay's origin: a proof made here works nowhere else.
125
- gateId: normalizeSuiAddress(gate.gateId),
126
- network: net,
127
- nftId,
128
- singleUse: state.singleUse.value,
129
- buildConsume: (id, nonce) => state.buildConsume(id, nonce),
130
- signAndExecute: (tx) => executor.signAndExecute(tx),
131
- waitForTransaction: (digest) => executor.waitForTransaction(digest),
132
- sign: signPersonalMessage,
133
- onStatus: opts.onStatus,
134
- })
135
- : undefined
136
-
137
- return runBlobUpload({
138
- bytes,
139
- network: net,
140
- relayHost: opts.relayHost,
141
- address,
142
- wasmUrl: walrusWasmUrl,
143
- maxTipMist: uploadRelayMaxTipMist(),
144
- epochs: opts.epochs,
145
- deletable: opts.deletable,
146
- force: opts.force,
147
- findExistingCopy,
148
- executor,
149
- suiClient: getSuiClient(),
150
- access,
151
- onStatus: opts.onStatus,
152
- // Persist the certificate the moment the upload lands, and drop it once certified — so a
153
- // dismissed certify can be finished from My Blobs after a tab switch or reload.
154
- onUploaded: (info) => savePendingCertify(storage, pendingCertifyKey(net, address), info),
155
- onCertified: (blobObjectId) => clearPendingCertify(storage, pendingCertifyKey(net, address), blobObjectId),
156
- })
157
- }
158
-
159
- // Precheck (before paying to register): does the wallet already own this exact blob? Returns a
160
- // `certified` match (offer Extend) or a `pending` one — uncertified but with a saved certificate
161
- // (offer Certify). Best-effort: any failure returns null so the upload simply proceeds.
162
- async function findExistingCopy(blobId: string): Promise<ExistingCopy | null> {
163
- const address = account.value?.address
164
- const net = walrusNetwork.value
165
- if (!address || !net) return null
166
- try {
167
- const { createWalrusClient, fetchOwnedWalrusBlobs } = await import('@meddleware/walrus-storage-client')
168
- const walrusClient = createWalrusClient({ network: net, wasmUrl: walrusWasmUrl })
169
- const [sys, owned] = await Promise.all([
170
- walrusClient.walrus.systemState(),
171
- fetchOwnedWalrusBlobs(getSuiClient(), walrusClient, address),
172
- ])
173
- const currentEpoch = Number(sys.committee.epoch)
174
- const copies = owned.filter((b) => b.blobId === blobId && b.endEpoch > currentEpoch)
175
- const certified = copies.find((b) => b.certified)
176
- if (certified) {
177
- return { kind: 'certified', blobId, objectId: certified.objectId, endEpoch: certified.endEpoch }
178
- }
179
- const store = loadPendingCertifies(storage, pendingCertifyKey(net, address))
180
- const pending = copies.find((b) => !b.certified && b.objectId in store)
181
- if (pending) {
182
- return { kind: 'pending', blobId, objectId: pending.objectId, endEpoch: pending.endEpoch }
183
- }
184
- return null
185
- } catch {
186
- return null
187
- }
188
- }
189
-
190
- // Injected into the upload widget so it can price a chosen reservation duration (storage is WAL,
191
- // billed per size × epochs). Returns total cost in FROST, or null on error.
192
- async function estimateUploadStorageCost(sizeBytes: number, epochs: number): Promise<bigint | null> {
193
- const net = walrusNetwork.value
194
- if (!net) return null
195
- try {
196
- const { createWalrusClient, estimateStorageCost } = await import('@meddleware/walrus-storage-client')
197
- const walrusClient = createWalrusClient({ network: net, wasmUrl: walrusWasmUrl })
198
- const cost = await estimateStorageCost(walrusClient, sizeBytes, epochs)
199
- return cost.totalCost
200
- } catch {
201
- return null
202
- }
203
- }
75
+ const { performUpload, estimateStorageCost } = useStorageUpload(() => gateState.value)
76
+ const ownedBlobs = useOwnedBlobs()
204
77
 
205
78
  // The user declined a duplicate upload and chose to manage the existing copy: jump to My Blobs and
206
79
  // highlight it (the grouped row exposes Extend for certified, Certify for pending).
@@ -210,167 +83,78 @@ function onManageExisting(existing: ExistingCopy): void {
210
83
  activeTab.value = 'blobs'
211
84
  }
212
85
 
213
- const ownedBlobs = useOwnedBlobs()
214
-
215
- function onUploaded(r: UploadResult): void {
216
- result.value = r
217
- }
218
-
219
- // Fires after every upload attempt (success or failure) — a failed UI run may still have landed
220
- // on-chain, so force-refresh the owned-blobs cache and NFT ownership (uses remaining) in the
221
- // background regardless of outcome.
86
+ // Fires after every upload attempt (success or failure): a failed run may still have landed on-chain, so force-refresh the
87
+ // owned-blobs cache and NFT ownership (uses remaining) in the background regardless of outcome.
222
88
  function onSettled(): void {
223
- if (account.value) {
224
- void ownedBlobs.load(account.value.address, { force: true })
225
- if (gateState.value.gate) void gateState.value.checkOwnership(account.value.address)
226
- }
89
+ if (!account.value) return
90
+ void ownedBlobs.load(account.value.address, { force: true })
91
+ if (gateState.value.gate) void gateState.value.checkOwnership(account.value.address)
227
92
  }
228
93
  </script>
229
94
 
230
95
  <template>
96
+ <section aria-labelledby="walrus-title">
97
+ <h2 id="walrus-title">Walrus storage</h2>
231
98
  <UiToolIntro>Upload and manage blobs on Walrus decentralised storage ({{ network }}).</UiToolIntro>
232
99
 
233
- <UiNotice v-if="!walrusNetwork" type="info">Walrus storage is not available on {{ network }}. Switch to testnet or mainnet.</UiNotice>
100
+ <UiNotice v-if="!walrusNetwork" type="info" :message="`Walrus storage is not available on ${network}. Switch to testnet or mainnet.`" />
234
101
  <template v-else>
235
-
236
- <AppTabNav v-model="activeTab" :tabs="TABS" id-prefix="walrus" aria-label="Feature tabs" class="walrus-tabs" />
237
-
238
- <!-- The tab list and its panel always render (the selected tab controls a live panel); the wallet
239
- prompt replaces only the panel's content until a wallet is connected. -->
240
- <UiTabPanel id-prefix="walrus" :tab="activeTab">
241
- <WalletGuard message="Connect a Sui wallet to upload and manage your blobs.">
242
- <template v-if="activeTab === 'upload'">
243
- <!-- Gated + no access: replace the form with the purchase CTA so the user is guided to buy
244
- first, rather than facing a disabled form. -->
245
- <div v-if="gateState.gateConfigured && gateState.hasAccess.value === false" class="gate-card">
246
- <AccessGateCta
247
- :gate-configured="gateState.gateConfigured"
248
- :has-access="gateState.hasAccess.value"
249
- :busy="purchasing"
250
- :terms="gateState.terms.value"
251
- :terms-error="gateState.termsError.value"
252
- :purchase-error="gateState.purchaseError.value"
253
- @purchase="onPurchase"
254
- />
255
- <button
256
- v-if="gateState.termsError.value && !gateState.termsLoading.value"
257
- type="button"
258
- class="retry-terms"
259
- @click="gateState.loadTerms()"
260
- >
261
- Read the price again
262
- </button>
263
- </div>
264
-
265
- <!-- Ownership check still in flight. -->
266
- <p
267
- v-else-if="gateState.gateConfigured && gateState.hasAccess.value === null"
268
- class="checking"
269
- >
270
- Checking access…
271
- </p>
272
-
273
- <!-- Access held (or ungated relay): show the upload form. -->
274
- <template v-else>
275
- <!-- Gated relays spend a credit before the file is stored — make the "attempt, not a
276
- guarantee" nature explicit, while reassuring that attempts resume. -->
277
- <UiNotice v-if="gateState.gateConfigured" type="info" class="credit-notice">
278
- Uploading spends <strong>one credit</strong> from your access NFT (an on-chain step)
279
- before the file is stored — it pays for an upload <em>attempt</em>, not a guaranteed
280
- upload. Your attempt resumes automatically, even after a page reload if you re-select
281
- the same file, so a credit is normally not lost. A credit is spent without a completed
282
- upload only if you abandon the upload entirely, cancel a required wallet approval, or
283
- wait long enough that the reserved storage lapses.
284
- </UiNotice>
285
-
286
- <WalrusUpload
287
- :hosts="relayHosts(walrusNetwork)"
288
- :connected="!!account"
289
- :access="{ gateConfigured: gateState.gateConfigured, hasAccess: gateState.hasAccess, singleUse: gateState.singleUse }"
290
- :session-key="`${walrusNetwork}:${account?.address ?? ''}`"
291
- :perform-upload="performUpload"
292
- :estimate-storage-cost="estimateUploadStorageCost"
293
- @uploaded="onUploaded"
294
- @settled="onSettled"
295
- @manage-existing="onManageExisting"
296
- />
297
-
298
- <section v-if="result" class="result">
299
- <h2>Uploaded ✓</h2>
300
- <p>
301
- <strong>Blob ID:</strong>
302
- <CopyableAddress :address="result.blobId" label="Copy blob ID">
303
- <ExplorerLink
304
- :href="walruscanBlobUrl(walrusNetwork, result.blobId)"
305
- :value="result.blobId"
306
- :chars="[8, 6]"
307
- />
308
- </CopyableAddress>
309
- </p>
310
- <p>
311
- <strong>URL:</strong>
312
- <a :href="safeHref(result.url)" target="_blank" rel="noopener noreferrer">{{ result.url }}</a>
313
- </p>
314
- <p v-if="result.digest">
315
- <strong>Certify tx:</strong>
316
- <CopyableAddress :address="result.digest" label="Copy transaction digest">
317
- <ExplorerLink
318
- :href="suiExplorerUrl('txblock', result.digest, walrusNetwork)"
319
- :value="result.digest"
320
- :chars="[8, 6]"
321
- />
322
- </CopyableAddress>
323
- </p>
324
- </section>
102
+ <AppTabNav v-model="activeTab" :tabs="TABS" id-prefix="walrus" aria-label="Feature tabs" />
103
+
104
+ <!-- The tab list and its panel always render (the selected tab controls a live panel); the wallet
105
+ prompt replaces only the panel's content until a wallet is connected. -->
106
+ <UiTabPanel id-prefix="walrus" :tab="activeTab">
107
+ <WalletGuard message="Connect a Sui wallet to upload and manage your blobs.">
108
+ <template v-if="activeTab === 'upload'">
109
+ <UiLiveStatus label="Access pass" :message="checkingAccess ? 'Checking access…' : ''" />
110
+
111
+ <!-- Gated + no access: replace the form with the purchase prompt, so the person is guided to buy first rather than
112
+ facing a disabled form. -->
113
+ <template v-if="needsPass">
114
+ <AccessGateCta
115
+ :gate-configured="gateState.gateConfigured"
116
+ :has-access="gateState.hasAccess.value"
117
+ :busy="purchasing"
118
+ :terms="gateState.terms.value"
119
+ :terms-error="gateState.termsError.value"
120
+ :purchase-error="gateState.purchaseError.value"
121
+ @purchase="onPurchase"
122
+ />
123
+ <UiButton v-if="gateState.termsError.value && !gateState.termsLoading.value" variant="secondary" @click="gateState.loadTerms()">
124
+ Read the price again
125
+ </UiButton>
126
+ </template>
127
+
128
+ <!-- Access held (or ungated relay): the upload form. -->
129
+ <template v-else-if="!checkingAccess">
130
+ <!-- Gated relays spend a credit before the file is stored: say that it pays for an attempt, not a guarantee, and
131
+ that attempts resume. -->
132
+ <UiNotice v-if="gateState.gateConfigured" type="info">
133
+ Uploading spends <strong>one credit</strong> from your access NFT (an on-chain step) before the file is stored — it pays
134
+ for an upload <em>attempt</em>, not a guaranteed upload. Your attempt resumes automatically, even after a page reload if
135
+ you re-select the same file, so a credit is normally not lost. A credit is spent without a completed upload only if you
136
+ abandon the upload entirely, cancel a required wallet approval, or wait long enough that the reserved storage lapses.
137
+ </UiNotice>
138
+
139
+ <WalrusUpload
140
+ :hosts="relayHosts(walrusNetwork)"
141
+ :connected="!!account"
142
+ :access="{ gateConfigured: gateState.gateConfigured, hasAccess: gateState.hasAccess, singleUse: gateState.singleUse }"
143
+ :session-key="`${walrusNetwork}:${account?.address ?? ''}`"
144
+ :perform-upload="performUpload"
145
+ :estimate-storage-cost="estimateStorageCost"
146
+ @uploaded="result = $event"
147
+ @settled="onSettled"
148
+ @manage-existing="onManageExisting"
149
+ />
150
+
151
+ <UploadResultCard v-if="result" :result="result" :network="walrusNetwork" />
152
+ </template>
325
153
  </template>
326
- </template>
327
154
 
328
- <MyBlobs
329
- v-if="activeTab === 'blobs'"
330
- :address="account?.address ?? null"
331
- :build-executor="() => buildExecutor()"
332
- :highlight-blob-id="highlightBlobId"
333
- />
334
- </WalletGuard>
335
- </UiTabPanel>
155
+ <MyBlobs v-else :address="account?.address ?? null" :build-executor="buildExecutor" :highlight-blob-id="highlightBlobId" />
156
+ </WalletGuard>
157
+ </UiTabPanel>
336
158
  </template>
159
+ </section>
337
160
  </template>
338
-
339
- <style scoped>
340
- .walrus-tabs {
341
- margin: 1rem 0 0.5rem;
342
- }
343
- .result {
344
- margin-top: 1.5rem;
345
- padding: 1rem;
346
- border: 1px solid var(--border);
347
- border-radius: 10px;
348
- word-break: break-all;
349
- }
350
-
351
- .result code {
352
- font-size: 0.85rem;
353
- }
354
-
355
- .gate-card {
356
- margin-top: 1rem;
357
- padding: 1.5rem;
358
- border: 1px solid var(--border);
359
- border-radius: 12px;
360
- background: var(--surface, transparent);
361
- text-align: center;
362
- }
363
-
364
- .checking {
365
- margin-top: 1.5rem;
366
- color: var(--muted);
367
- text-align: center;
368
- }
369
-
370
- .credit-notice {
371
- margin: 1rem 0;
372
- font-size: 0.85rem;
373
- line-height: 1.5;
374
- }
375
-
376
- </style>
@@ -1,7 +1,7 @@
1
1
  // Prices an Extend before the wallet opens (audit F12). A row's quote is the WAL cost of adding
2
2
  // `epochs`; Extend opens the wallet only for an amount whose quote the user has been shown. A quote is
3
3
  // an estimate (labelled `≈`): the wallet and the chain still decide the amount actually paid.
4
- import { ref } from 'vue'
4
+ import { shallowRef } from 'vue'
5
5
 
6
6
  export type ExtendQuote =
7
7
  | { epochs: number; state: 'ready'; costFrost: bigint }
@@ -12,8 +12,8 @@ export type EstimateExtendCost = (size: number, epochs: number) => Promise<bigin
12
12
 
13
13
  export function useExtendQuotes(estimate: EstimateExtendCost) {
14
14
  /** Latest quote per Blob object id (one per row). */
15
- const quotes = ref<Record<string, ExtendQuote>>({})
16
- const pricing = ref<Record<string, number>>({})
15
+ const quotes = shallowRef<Record<string, ExtendQuote>>({})
16
+ const pricing = shallowRef<Record<string, number>>({})
17
17
  /** Per-row request counter: an older answer never replaces a newer amount's quote. */
18
18
  const tokens: Record<string, number> = {}
19
19
 
@@ -6,14 +6,14 @@
6
6
  // `load()` is a no-op when the list is already loaded for the given address on the active network,
7
7
  // unless `force` is set
8
8
  // (used after an upload/extend, which can create or mutate on-chain blobs).
9
- import { ref } from 'vue'
9
+ import { ref, shallowRef } from 'vue'
10
10
  import type { OwnedBlob } from '@meddleware/walrus-storage-client'
11
- // Lightweight URL import — just the wasm asset URL (does not pull the walrus client eagerly).
12
- import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
11
+ import { errorMessage } from '@meddleware/web-utils'
13
12
  import { getSuiClient } from '../wallet.js'
14
13
  import { walrusNetwork } from '../config.js'
14
+ import { walrusClient, walrusLib } from '../walrus.js'
15
15
 
16
- const blobs = ref<OwnedBlob[]>([])
16
+ const blobs = shallowRef<OwnedBlob[]>([])
17
17
  const currentEpoch = ref(0)
18
18
  const loading = ref(false)
19
19
  const error = ref<string | null>(null)
@@ -58,15 +58,14 @@ async function load(address: string | null, opts: { force?: boolean } = {}): Pro
58
58
  loading.value = true
59
59
  error.value = null
60
60
  try {
61
- const { createWalrusClient, fetchOwnedWalrusBlobs } = await import('@meddleware/walrus-storage-client')
61
+ const [{ fetchOwnedWalrusBlobs }, client] = await Promise.all([walrusLib(), walrusClient(net)])
62
62
  const suiClient = getSuiClient()
63
- const walrusClient = createWalrusClient({ network: net, wasmUrl: walrusWasmUrl })
64
63
  // Blob `endEpoch` is a WALRUS epoch, so compare against the Walrus committee epoch — NOT the
65
64
  // Sui system-state epoch (they are different clocks; the Sui epoch, ~1218 vs ~570, made every
66
65
  // blob read as "expired").
67
66
  const [sys, fetched] = await Promise.all([
68
- walrusClient.walrus.systemState(),
69
- fetchOwnedWalrusBlobs(suiClient, walrusClient, address),
67
+ client.walrus.systemState(),
68
+ fetchOwnedWalrusBlobs(suiClient, client, address),
70
69
  ])
71
70
  // A newer load (or a switch) took over while this one was in flight: its answer is for another
72
71
  // account or network, so drop it without touching the list, the error or `loading`.
@@ -76,7 +75,7 @@ async function load(address: string | null, opts: { force?: boolean } = {}): Pro
76
75
  loadedFor.value = key
77
76
  } catch (e) {
78
77
  if (mine !== latest) return
79
- error.value = e instanceof Error ? e.message : String(e)
78
+ error.value = errorMessage(e)
80
79
  } finally {
81
80
  if (mine === latest) {
82
81
  loading.value = false
@@ -0,0 +1,111 @@
1
+ // The Upload tab's orchestration: wires the shared `WalrusUpload` widget to walrus-storage-client's upload flow and the wallet.
2
+ // `runBlobUpload` registers fresh on every attempt (the relay rejects an old register transaction); for a gated relay `useGatedUpload`
3
+ // keeps the single-use consume resumable (its digest is persisted before the upload, reused after an interruption, cleared once the
4
+ // upload lands, and replaced only after the gateway reports it redeemed).
5
+ import { useGatedUpload, type AccessGateState } from '@meddleware/walrus-storage-kit'
6
+ import {
7
+ browserStorage,
8
+ clearPendingCertify,
9
+ loadPendingCertifies,
10
+ pendingCertifyKey,
11
+ runBlobUpload,
12
+ savePendingCertify,
13
+ type BlobUploadResult,
14
+ type ExistingCopy,
15
+ type UploadProgress,
16
+ } from '@meddleware/walrus-storage-client/flow'
17
+ import { network, relayHosts, uploadRelayMaxTipMist, walrusNetwork } from '../config.js'
18
+ import { buildExecutor, getSuiClient, useWallet } from '../wallet.js'
19
+ import { walrusClient, walrusLib, walrusWasmUrl } from '../walrus.js'
20
+
21
+ export type UploadResult = BlobUploadResult
22
+
23
+ export interface UploadOptions {
24
+ relayHost: string
25
+ epochs: number
26
+ force?: boolean
27
+ deletable?: boolean
28
+ onStatus: (s: string | UploadProgress) => void
29
+ }
30
+
31
+ /** `gate` is the active network's access-gate state (a getter: it changes with the network). */
32
+ export function useStorageUpload(gate: () => AccessGateState) {
33
+ // Browser storage that never throws (blocked site data falls back to memory for this page).
34
+ const storage = browserStorage()
35
+ const { account, signPersonalMessage } = useWallet()
36
+ const { accessFor } = useGatedUpload(gate)
37
+
38
+ // Precheck (before paying to register): does the wallet already own this exact blob? Returns a `certified` match (offer Extend) or a
39
+ // `pending` one: uncertified but with a saved certificate (offer Certify). Best-effort: any failure returns null so the upload proceeds.
40
+ async function findExistingCopy(blobId: string): Promise<ExistingCopy | null> {
41
+ const address = account.value?.address
42
+ const net = walrusNetwork.value
43
+ if (!address || !net) return null
44
+ try {
45
+ const [{ fetchOwnedWalrusBlobs }, client] = await Promise.all([walrusLib(), walrusClient(net)])
46
+ const [sys, owned] = await Promise.all([client.walrus.systemState(), fetchOwnedWalrusBlobs(getSuiClient(), client, address)])
47
+ const currentEpoch = Number(sys.committee.epoch)
48
+ const copies = owned.filter((b) => b.blobId === blobId && b.endEpoch > currentEpoch)
49
+ const certified = copies.find((b) => b.certified)
50
+ if (certified) return { kind: 'certified', blobId, objectId: certified.objectId, endEpoch: certified.endEpoch }
51
+ const store = loadPendingCertifies(storage, pendingCertifyKey(net, address))
52
+ const pending = copies.find((b) => !b.certified && b.objectId in store)
53
+ return pending ? { kind: 'pending', blobId, objectId: pending.objectId, endEpoch: pending.endEpoch } : null
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
59
+ async function performUpload(bytes: Uint8Array, opts: UploadOptions): Promise<UploadResult> {
60
+ if (!account.value) throw new Error('Connect your wallet first.')
61
+ const net = walrusNetwork.value
62
+ if (!net) throw new Error(`Walrus storage is not available on ${network.value}.`)
63
+ const executor = await buildExecutor()
64
+ const address = account.value.address
65
+ const access = accessFor({
66
+ relayHost: opts.relayHost,
67
+ publicHost: relayHosts(net).public,
68
+ address,
69
+ network: net,
70
+ executor,
71
+ sign: signPersonalMessage,
72
+ onStatus: opts.onStatus,
73
+ storage,
74
+ })
75
+ return runBlobUpload({
76
+ bytes,
77
+ network: net,
78
+ relayHost: opts.relayHost,
79
+ address,
80
+ wasmUrl: walrusWasmUrl,
81
+ maxTipMist: uploadRelayMaxTipMist(),
82
+ epochs: opts.epochs,
83
+ deletable: opts.deletable,
84
+ force: opts.force,
85
+ findExistingCopy,
86
+ executor,
87
+ suiClient: getSuiClient(),
88
+ access,
89
+ onStatus: opts.onStatus,
90
+ // Persist the certificate the moment the upload lands, and drop it once certified, so a dismissed certify can be finished
91
+ // from My Blobs after a tab switch or reload.
92
+ onUploaded: (info) => savePendingCertify(storage, pendingCertifyKey(net, address), info),
93
+ onCertified: (blobObjectId) => clearPendingCertify(storage, pendingCertifyKey(net, address), blobObjectId),
94
+ })
95
+ }
96
+
97
+ // Injected into the upload widget so it can price a chosen reservation duration (storage is WAL, billed per size × epochs).
98
+ // Returns the total cost in FROST, or null when it cannot be priced.
99
+ async function estimateStorageCost(sizeBytes: number, epochs: number): Promise<bigint | null> {
100
+ const net = walrusNetwork.value
101
+ if (!net) return null
102
+ try {
103
+ const [{ estimateStorageCost: estimate }, client] = await Promise.all([walrusLib(), walrusClient(net)])
104
+ return (await estimate(client, sizeBytes, epochs)).totalCost
105
+ } catch {
106
+ return null
107
+ }
108
+ }
109
+
110
+ return { performUpload, estimateStorageCost }
111
+ }