@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.
- package/CHANGELOG.md +74 -0
- package/LICENSE +14 -0
- package/README.md +72 -2
- package/index.html +14 -0
- package/package.json +93 -4
- package/src/App.vue +56 -0
- package/src/blob-groups.ts +68 -0
- package/src/components/MyBlobs.vue +442 -0
- package/src/components/WalrusStorageView.vue +376 -0
- package/src/composables/useExtendQuotes.ts +69 -0
- package/src/composables/useOwnedBlobs.ts +91 -0
- package/src/config.ts +87 -0
- package/src/env.d.ts +7 -0
- package/src/index.ts +10 -0
- package/src/main.ts +16 -0
- package/src/wallet.ts +47 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +13 -0
|
@@ -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
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')
|