@meddleware/walrus-storage-ui 0.0.0-stage → 0.1.62

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.
@@ -0,0 +1,376 @@
1
+ <script setup lang="ts">
2
+ // Core Walrus tool UI (upload + owned-blob management), free of any app shell (header/footer).
3
+ // Rendered standalone by walrus-storage-ui's App.vue and inline by the dashboard. Wallet state comes
4
+ // from the shared @meddleware/wallet-adapter singleton (via ./wallet.js), so connecting here or
5
+ // in any other inline tool view reflects everywhere.
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'
17
+ 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()
36
+ import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
37
+ import MyBlobs from './MyBlobs.vue'
38
+
39
+ const TABS: AppTab[] = [
40
+ { id: 'upload', label: 'Upload' },
41
+ { id: 'blobs', label: 'My Blobs' },
42
+ ]
43
+ const activeTab = ref<string>('upload')
44
+
45
+ const { account, signPersonalMessage, buildExecutor } = useWallet()
46
+
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 }),
59
+ }
60
+ /** 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'])
62
+ const purchasing = ref(false)
63
+ const result = ref<UploadResult | null>(null)
64
+
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.
68
+ watch(
69
+ [() => account.value?.address ?? null, walrusNetwork],
70
+ ([addr], previous) => {
71
+ const prevAddr = previous?.[0] ?? null
72
+ if (addr !== prevAddr) for (const s of Object.values(gateStates)) s.reset()
73
+ else gateState.value.reset()
74
+ result.value = null
75
+ if (addr && walrusNetwork.value && gateState.value.gate) void gateState.value.checkOwnership(addr)
76
+ },
77
+ { immediate: true },
78
+ )
79
+
80
+ async function onPurchase(): Promise<void> {
81
+ if (!account.value) return
82
+ purchasing.value = true
83
+ try {
84
+ const executor = await buildExecutor()
85
+ await gateState.value.purchase(executor, account.value.address)
86
+ } catch {
87
+ // The reason is published in `purchaseError` and shown by the prompt.
88
+ } finally {
89
+ purchasing.value = false
90
+ }
91
+ }
92
+
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
+ }
204
+
205
+ // The user declined a duplicate upload and chose to manage the existing copy: jump to My Blobs and
206
+ // highlight it (the grouped row exposes Extend for certified, Certify for pending).
207
+ const highlightBlobId = ref<string | null>(null)
208
+ function onManageExisting(existing: ExistingCopy): void {
209
+ highlightBlobId.value = existing.blobId
210
+ activeTab.value = 'blobs'
211
+ }
212
+
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.
222
+ 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
+ }
227
+ }
228
+ </script>
229
+
230
+ <template>
231
+ <UiToolIntro>Upload and manage blobs on Walrus decentralised storage ({{ network }}).</UiToolIntro>
232
+
233
+ <UiNotice v-if="!walrusNetwork" type="info">Walrus storage is not available on {{ network }}. Switch to testnet or mainnet.</UiNotice>
234
+ <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>
325
+ </template>
326
+ </template>
327
+
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>
336
+ </template>
337
+ </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>
@@ -0,0 +1,69 @@
1
+ // Prices an Extend before the wallet opens (audit F12). A row's quote is the WAL cost of adding
2
+ // `epochs`; Extend opens the wallet only for an amount whose quote the user has been shown. A quote is
3
+ // an estimate (labelled `≈`): the wallet and the chain still decide the amount actually paid.
4
+ import { ref } from 'vue'
5
+
6
+ export type ExtendQuote =
7
+ | { epochs: number; state: 'ready'; costFrost: bigint }
8
+ | { epochs: number; state: 'unavailable'; costFrost: null }
9
+
10
+ /** Cost in FROST of storing `size` bytes for `epochs`, or `null` when it cannot be priced. */
11
+ export type EstimateExtendCost = (size: number, epochs: number) => Promise<bigint | null>
12
+
13
+ export function useExtendQuotes(estimate: EstimateExtendCost) {
14
+ /** Latest quote per Blob object id (one per row). */
15
+ const quotes = ref<Record<string, ExtendQuote>>({})
16
+ const pricing = ref<Record<string, number>>({})
17
+ /** Per-row request counter: an older answer never replaces a newer amount's quote. */
18
+ const tokens: Record<string, number> = {}
19
+
20
+ /** Price `epochs` for the row; resolves to its quote, or `null` if a newer request replaced this one. */
21
+ async function price(id: string, size: number, epochs: number): Promise<ExtendQuote | null> {
22
+ const token = (tokens[id] = (tokens[id] ?? 0) + 1)
23
+ pricing.value = { ...pricing.value, [id]: epochs }
24
+ let cost: bigint | null
25
+ try {
26
+ cost = await estimate(size, epochs)
27
+ } catch {
28
+ cost = null
29
+ }
30
+ if (tokens[id] !== token) return null
31
+ const rest = { ...pricing.value }
32
+ delete rest[id]
33
+ pricing.value = rest
34
+ const quote: ExtendQuote =
35
+ cost === null ? { epochs, state: 'unavailable', costFrost: null } : { epochs, state: 'ready', costFrost: cost }
36
+ quotes.value = { ...quotes.value, [id]: quote }
37
+ return quote
38
+ }
39
+
40
+ /** The row's quote, only if it is for exactly `epochs` (an older amount's quote is not shown). */
41
+ function quoteFor(id: string, epochs: number | undefined): ExtendQuote | null {
42
+ const q = quotes.value[id]
43
+ return q && q.epochs === epochs ? q : null
44
+ }
45
+
46
+ /**
47
+ * Whether Extend may open the wallet now: only when the quote for `epochs` has been shown. If it has
48
+ * not (the seeded default, or a price still loading), it is fetched now, so the user sees the cost
49
+ * and presses Extend again — never a wallet prompt for an unpriced amount.
50
+ */
51
+ async function shownBeforeSigning(id: string, size: number, epochs: number): Promise<boolean> {
52
+ if (quoteFor(id, epochs)) return true
53
+ await price(id, size, epochs)
54
+ return false
55
+ }
56
+
57
+ /** Forget the row's quote (after its blob was extended, the next Extend is priced afresh). */
58
+ function clear(id: string): void {
59
+ tokens[id] = (tokens[id] ?? 0) + 1
60
+ const q = { ...quotes.value }
61
+ delete q[id]
62
+ quotes.value = q
63
+ const p = { ...pricing.value }
64
+ delete p[id]
65
+ pricing.value = p
66
+ }
67
+
68
+ return { quotes, pricing, price, quoteFor, shownBeforeSigning, clear }
69
+ }
@@ -0,0 +1,91 @@
1
+ // Module-singleton cache of the connected wallet's owned Walrus blobs.
2
+ //
3
+ // State lives at module scope (not inside the composable factory) so it is shared across every
4
+ // `MyBlobs` mount in the session: switching the Upload/My-Blobs tabs — or navigating the whole
5
+ // tool view in and out inline in the dashboard — no longer loses the list or forces a refetch.
6
+ // `load()` is a no-op when the list is already loaded for the given address on the active network,
7
+ // unless `force` is set
8
+ // (used after an upload/extend, which can create or mutate on-chain blobs).
9
+ import { ref } from 'vue'
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'
13
+ import { getSuiClient } from '../wallet.js'
14
+ import { walrusNetwork } from '../config.js'
15
+
16
+ const blobs = ref<OwnedBlob[]>([])
17
+ const currentEpoch = ref(0)
18
+ const loading = ref(false)
19
+ const error = ref<string | null>(null)
20
+ /** `<network>|<address>` the current `blobs` were fetched for; `null` until a successful load. */
21
+ const loadedFor = ref<string | null>(null)
22
+
23
+ /** Counts calls that decide what the list shows; a result is applied only if it is still the latest. */
24
+ let latest = 0
25
+ /** Key of the load in flight (the latest one), or `null` when none is. */
26
+ let inflightKey: string | null = null
27
+
28
+ /** Drop whatever load is in flight: its result will be discarded and it no longer owns `loading`. */
29
+ function supersede(): void {
30
+ latest++
31
+ inflightKey = null
32
+ loading.value = false
33
+ }
34
+
35
+ async function load(address: string | null, opts: { force?: boolean } = {}): Promise<void> {
36
+ const net = walrusNetwork.value
37
+ if (!address || !net) {
38
+ supersede()
39
+ blobs.value = []
40
+ loadedFor.value = null
41
+ return
42
+ }
43
+ // Skip refetch when we already have this address's list and the last load succeeded.
44
+ const key = `${net}|${address}`
45
+ if (!opts.force && loadedFor.value === key && error.value === null) {
46
+ // Switching back to a list we hold: a slower load for another account or network must not land.
47
+ if (inflightKey !== null && inflightKey !== key) supersede()
48
+ return
49
+ }
50
+ if (loadedFor.value !== key) {
51
+ // Never show another network's or wallet's list; and a cleared list must not read as loaded.
52
+ blobs.value = []
53
+ loadedFor.value = null
54
+ }
55
+
56
+ const mine = ++latest
57
+ inflightKey = key
58
+ loading.value = true
59
+ error.value = null
60
+ try {
61
+ const { createWalrusClient, fetchOwnedWalrusBlobs } = await import('@meddleware/walrus-storage-client')
62
+ const suiClient = getSuiClient()
63
+ const walrusClient = createWalrusClient({ network: net, wasmUrl: walrusWasmUrl })
64
+ // Blob `endEpoch` is a WALRUS epoch, so compare against the Walrus committee epoch — NOT the
65
+ // Sui system-state epoch (they are different clocks; the Sui epoch, ~1218 vs ~570, made every
66
+ // blob read as "expired").
67
+ const [sys, fetched] = await Promise.all([
68
+ walrusClient.walrus.systemState(),
69
+ fetchOwnedWalrusBlobs(suiClient, walrusClient, address),
70
+ ])
71
+ // A newer load (or a switch) took over while this one was in flight: its answer is for another
72
+ // account or network, so drop it without touching the list, the error or `loading`.
73
+ if (mine !== latest) return
74
+ currentEpoch.value = Number(sys.committee.epoch)
75
+ blobs.value = fetched.sort((a: OwnedBlob, b: OwnedBlob) => a.endEpoch - b.endEpoch)
76
+ loadedFor.value = key
77
+ } catch (e) {
78
+ if (mine !== latest) return
79
+ error.value = e instanceof Error ? e.message : String(e)
80
+ } finally {
81
+ if (mine === latest) {
82
+ loading.value = false
83
+ inflightKey = null
84
+ }
85
+ }
86
+ }
87
+
88
+ /** Reactive access to the shared owned-blobs cache and its loader. */
89
+ export function useOwnedBlobs() {
90
+ return { blobs, currentEpoch, loading, error, loadedFor, load }
91
+ }
package/src/config.ts ADDED
@@ -0,0 +1,87 @@
1
+ // Configuration. The network is wallet-adapter's shared runtime selector (the standalone build
2
+ // selects VITE_NETWORK in main.ts; embedded, the host's selector rules). Operator settings are
3
+ // build-time VITE_* per network: relay hostnames default to the public Mysten relay (set
4
+ // VITE_WALRUS_RELAY_* to your operator relay to collect the tip); the access gate is optional
5
+ // (unset ⇒ ungated), and its package + PlatformConfig come from the published deployment; the
6
+ // purchase price and payees are read from the chain, never configured.
7
+ import { computed } from 'vue'
8
+ import type { WalrusNetwork, RelayGateConfig } from '@meddleware/walrus-storage-kit'
9
+ import { relayGateConfig } from '@meddleware/walrus-storage-kit'
10
+ import { useNetwork } from '@meddleware/wallet-adapter'
11
+
12
+ /** Build-time env bag. Injectable on the env-reading helpers below purely so unit tests can exercise
13
+ * each branch without depending on Vite's (frozen) `import.meta.env` inlining. */
14
+ export type EnvSource = Record<string, string | undefined>
15
+
16
+ const env: EnvSource = (import.meta as unknown as { env?: EnvSource }).env ?? {}
17
+
18
+ /** The active network (read-only ref). */
19
+ export const network = useNetwork().network
20
+
21
+ /** The active network as a Walrus network, or null where Walrus has none (e.g. localnet). */
22
+ export const walrusNetwork = computed<WalrusNetwork | null>(() =>
23
+ network.value === 'testnet' || network.value === 'mainnet' ? network.value : null,
24
+ )
25
+
26
+ /** Public Mysten relays — the fallback when no operator relay is configured. */
27
+ export const PUBLIC_WALRUS_RELAY_HOSTS: Record<WalrusNetwork, string> = {
28
+ testnet: 'https://upload-relay.testnet.walrus.space',
29
+ mainnet: 'https://upload-relay.mainnet.walrus.space',
30
+ }
31
+
32
+ /** Operator relay host per network (env → public fallback). */
33
+ export const OPERATOR_RELAY_HOSTS: Record<WalrusNetwork, string> = {
34
+ testnet: env.VITE_WALRUS_RELAY_TESTNET || PUBLIC_WALRUS_RELAY_HOSTS.testnet,
35
+ mainnet: env.VITE_WALRUS_RELAY_MAINNET || PUBLIC_WALRUS_RELAY_HOSTS.mainnet,
36
+ }
37
+
38
+ /** The operator/public host pair for `useWalrusRelay`. */
39
+ export function relayHosts(network: WalrusNetwork): { operator: string; public: string } {
40
+ return { operator: OPERATOR_RELAY_HOSTS[network], public: PUBLIC_WALRUS_RELAY_HOSTS[network] }
41
+ }
42
+
43
+ /**
44
+ * Walruscan explorer URL for a blob. A Walrus blob id is not a Sui object, so it links to the
45
+ * Walrus-native explorer rather than a Sui explorer (SuiVision handles Sui entities elsewhere).
46
+ */
47
+ export function walruscanBlobUrl(network: WalrusNetwork, blobId: string): string {
48
+ return `https://walruscan.com/${network}/blob/${blobId}`
49
+ }
50
+
51
+ /**
52
+ * Default relay tip ceiling (MIST) when `VITE_UPLOAD_RELAY_MAX_TIP_MIST` is unset: 0.005 SUI, the
53
+ * workspace-wide ceiling (~8× the operator relay's worst-case tip at the 100 MiB edge cap).
54
+ */
55
+ export const DEFAULT_UPLOAD_RELAY_MAX_TIP_MIST = 5_000_000
56
+
57
+ /**
58
+ * Cap on the relay tip payment in MIST, from `VITE_UPLOAD_RELAY_MAX_TIP_MIST`. This is a ceiling to
59
+ * prevent overpayment (the relay asks for the minimum), NOT a fixed charge. A non-positive or
60
+ * unparseable value falls back to {@link DEFAULT_UPLOAD_RELAY_MAX_TIP_MIST}.
61
+ */
62
+ export function uploadRelayMaxTipMist(envSource: EnvSource = env): number {
63
+ const parsed = Number(envSource.VITE_UPLOAD_RELAY_MAX_TIP_MIST)
64
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_UPLOAD_RELAY_MAX_TIP_MIST
65
+ }
66
+
67
+ /**
68
+ * The optional NFT access gate for a network: the operator supplies only the Gate id and soulbound
69
+ * flag (`VITE_ACCESS_GATE_*_{NET}`); `relayGateConfig` fixes the package and `PlatformConfig` to the
70
+ * published deployment (commission enforcement), and the price is read from the gate on the chain.
71
+ * `null` — an ungated
72
+ * relay — when no gate id is set, or when no access_gate deployment is recorded for the network.
73
+ */
74
+ export function accessGate(network: WalrusNetwork, envSource: EnvSource = env): RelayGateConfig | null {
75
+ const NET = network.toUpperCase()
76
+ const gateId = envSource[`VITE_ACCESS_GATE_ID_${NET}`]
77
+ if (!gateId) return null
78
+ try {
79
+ return relayGateConfig(network, {
80
+ gateId,
81
+ soulbound: envSource[`VITE_ACCESS_GATE_SOULBOUND_${NET}`] === 'true',
82
+ })
83
+ } catch (e) {
84
+ console.warn(`[walrus-storage-ui] access gate for ${network} disabled: ${e instanceof Error ? e.message : String(e)}`)
85
+ return null
86
+ }
87
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module '*.vue' {
4
+ import type { DefineComponent } from 'vue'
5
+ const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
6
+ export default component
7
+ }
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ // @meddleware/walrus-storage-ui — library entry.
2
+ //
3
+ // Exports the core Walrus tool view (no app shell) for inline embedding in the dashboard.
4
+ // The standalone SPA (App.vue + main.ts) is unaffected and still builds/deploys as before.
5
+ //
6
+ // Consumers must import the shared design tokens + UI base once at their entry:
7
+ // import '@meddleware/design-tokens/tokens.css'
8
+ // import '@meddleware/ui/base.css'
9
+
10
+ export { default as WalrusStorageView } from './components/WalrusStorageView.vue'
package/src/main.ts ADDED
@@ -0,0 +1,16 @@
1
+ import '@meddleware/design-tokens/tokens.css'
2
+ import '@meddleware/design-tokens/seasons.css'
3
+ import '@meddleware/ui/base.css'
4
+ import { createApp } from 'vue'
5
+ import { useSeason } from '@meddleware/ui'
6
+ import { useNetwork } from '@meddleware/wallet-adapter'
7
+ import App from './App.vue'
8
+
9
+ // The standalone build targets one network; select it in the shared selector so the client, the
10
+ // relay, the gate and the stored resume keys all agree. (Embedded, the host's selector rules.)
11
+ const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env ?? {}
12
+ const built = env.VITE_NETWORK || 'testnet'
13
+ if (built === 'testnet' || built === 'mainnet') useNetwork().setNetwork(built)
14
+
15
+ useSeason()
16
+ createApp(App).mount('#app')