@meddleware/walrus-ui 0.1.26 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meddleware/walrus-ui",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "Standalone Vue 3 SPA for uploading blobs to Walrus decentralised storage and managing owned blobs — wallet-connected, NFT-gated relay support.",
5
5
  "author": "Meddleware <dev@meddleware.co.uk>",
6
6
  "license": "0BSD",
@@ -44,8 +44,8 @@
44
44
  "@meddleware/nft-gate-client": "^0.0.6",
45
45
  "@meddleware/ui": "^0.1.10",
46
46
  "@meddleware/wallet-adapter": "^0.0.5",
47
- "@meddleware/walrus-client": "^0.0.10",
48
- "@meddleware/walrus-relay": "^0.1.10",
47
+ "@meddleware/walrus-client": "^0.0.11",
48
+ "@meddleware/walrus-relay": "^0.1.11",
49
49
  "@mysten/sui": "^2.30.0",
50
50
  "@mysten/wallet-standard": "^0.20.0",
51
51
  "@mysten/walrus": "~1.2.24",
@@ -0,0 +1,68 @@
1
+ // Group a wallet's owned Walrus blobs by `blobId` for display. Re-uploading the same content creates
2
+ // multiple independent Blob objects (each its own storage reservation); grouping collapses those into
3
+ // one row so My Blobs matches how Walruscan thinks per-blobId, while still exposing each copy.
4
+ import type { OwnedBlob } from '@meddleware/walrus-client'
5
+
6
+ export interface BlobGroup {
7
+ /** Shared Walrus blob id. */
8
+ blobId: string
9
+ /** Size in bytes (identical across copies of the same content). */
10
+ size: number
11
+ /** Furthest storage end epoch across copies — the effective availability of this content. */
12
+ maxEndEpoch: number
13
+ /** True if any copy is certified (i.e. the content is currently available on-chain). */
14
+ anyCertified: boolean
15
+ /** The copy that defines availability (max end epoch); the default target for Extend. */
16
+ representative: OwnedBlob
17
+ /** All underlying Blob objects for this blobId, furthest-expiry first. */
18
+ copies: OwnedBlob[]
19
+ }
20
+
21
+ /** Collapse owned blobs into per-blobId groups, each sorted by end epoch (furthest first). */
22
+ export function groupBlobs(blobs: OwnedBlob[]): BlobGroup[] {
23
+ const byId = new Map<string, OwnedBlob[]>()
24
+ for (const b of blobs) {
25
+ const list = byId.get(b.blobId)
26
+ if (list) list.push(b)
27
+ else byId.set(b.blobId, [b])
28
+ }
29
+ const groups: BlobGroup[] = []
30
+ for (const [blobId, copies] of byId) {
31
+ const sorted = [...copies].sort((a, b) => b.endEpoch - a.endEpoch)
32
+ const representative = sorted[0]
33
+ groups.push({
34
+ blobId,
35
+ size: representative.size,
36
+ maxEndEpoch: representative.endEpoch,
37
+ anyCertified: sorted.some((b) => b.certified),
38
+ representative,
39
+ copies: sorted,
40
+ })
41
+ }
42
+ // Most-expiring-soonest groups first (matches the pre-grouping sort), so attention lands on them.
43
+ return groups.sort((a, b) => a.maxEndEpoch - b.maxEndEpoch)
44
+ }
45
+
46
+ /** Epochs of storage left before expiry (clamped at 0). */
47
+ export function epochsLeft(endEpoch: number, currentEpoch: number): number {
48
+ return Math.max(0, endEpoch - currentEpoch)
49
+ }
50
+
51
+ /** Human label for a blob's remaining lifetime, transport-independent (no fabricated day estimate). */
52
+ export function expiryLabel(endEpoch: number, currentEpoch: number): string {
53
+ const left = epochsLeft(endEpoch, currentEpoch)
54
+ if (left <= 0) return `epoch ${endEpoch} · expired`
55
+ return `epoch ${endEpoch} · ${left} epoch${left === 1 ? '' : 's'} left`
56
+ }
57
+
58
+ /**
59
+ * Epochs that can still be added to a blob before hitting `max_epochs_ahead` (a blob's end epoch
60
+ * cannot exceed `currentEpoch + maxReservation`). Zero means it's already at max lifetime.
61
+ */
62
+ export function maxExtendableEpochs(
63
+ endEpoch: number,
64
+ currentEpoch: number,
65
+ maxReservation: number,
66
+ ): number {
67
+ return Math.max(0, currentEpoch + maxReservation - endEpoch)
68
+ }
@@ -1,11 +1,13 @@
1
1
  <script setup lang="ts">
2
- import { onMounted, ref, watch } from 'vue'
2
+ import { computed, nextTick, onMounted, ref, watch } from 'vue'
3
3
  import { CopyableAddress, ExplorerLink } from '@meddleware/ui'
4
4
  import type { OwnedBlob } from '@meddleware/walrus-client'
5
+ import { MAX_SINGLE_RESERVATION_EPOCHS } from '@meddleware/walrus-relay'
5
6
  import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
6
7
  import type { Executor } from '../wallet.js'
7
8
  import { NETWORK, walruscanBlobUrl } from '../config.js'
8
9
  import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
10
+ import { groupBlobs, expiryLabel, maxExtendableEpochs, type BlobGroup } from '../blob-groups.js'
9
11
  import {
10
12
  pendingCertifyKey,
11
13
  loadPendingCertifies,
@@ -16,24 +18,65 @@ import {
16
18
  const props = defineProps<{
17
19
  /** Connected wallet address whose owned blobs to list; `null` when no wallet is connected. */
18
20
  address: string | null
19
- /** Factory that builds a transaction {@link Executor} bound to the connected wallet (for extend). */
21
+ /** Factory that builds a transaction {@link Executor} bound to the connected wallet. */
20
22
  buildExecutor: () => Promise<Executor>
23
+ /** When set, the matching group is expanded + scrolled into view (from the duplicate-upload flow). */
24
+ highlightBlobId?: string | null
21
25
  }>()
22
26
 
23
27
  // Shared, session-persistent cache (survives tab switches and inline re-mounts).
24
28
  const { blobs, currentEpoch, loading, error, load } = useOwnedBlobs()
25
- const extending = ref<string | null>(null)
26
- const extendStatus = ref<Record<string, string>>({})
27
29
 
28
- // Pending certifications: blobs uploaded + paid for but not yet certified (the certify prompt was
29
- // dismissed). Persisted by the upload flow, keyed by blobObjectId; resumable here without re-upload.
30
+ // One row per blobId; re-uploads of the same content are collapsed into a group's `copies`.
31
+ const groups = computed(() => groupBlobs(blobs.value))
32
+ const expanded = ref<Set<string>>(new Set())
33
+
34
+ // Pending certifications (uploaded-but-not-certified copies we still hold a certificate for).
30
35
  const pending = ref<Record<string, PendingCertify>>({})
31
- const certifying = ref<string | null>(null)
32
- const certifyStatus = ref<Record<string, string>>({})
33
36
 
34
- /** The pending-certify entry for a blob, if it's uncertified and we hold its certificate. */
35
- function pendingFor(blob: OwnedBlob): PendingCertify | null {
36
- return !blob.certified ? (pending.value[blob.objectId] ?? null) : null
37
+ // Per-object action state (keyed by Blob objectId), shared by Extend and Certify.
38
+ const busy = ref<string | null>(null)
39
+ const actionStatus = ref<Record<string, string>>({})
40
+ const extendAmount = ref<Record<string, number>>({})
41
+ const extendCostFrost = ref<Record<string, bigint | null>>({})
42
+
43
+ const MAX = MAX_SINGLE_RESERVATION_EPOCHS
44
+
45
+ function msg(e: unknown): string {
46
+ return e instanceof Error ? e.message : String(e)
47
+ }
48
+ /** FROST (1e-9 WAL) → short WAL string. */
49
+ function toWal(frost: bigint): string {
50
+ return (Number(frost) / 1e9).toFixed(4)
51
+ }
52
+
53
+ function maxAddable(endEpoch: number): number {
54
+ return maxExtendableEpochs(endEpoch, currentEpoch.value, MAX)
55
+ }
56
+
57
+ /** The stored certificate for an uncertified copy, if we can certify it here. */
58
+ function pendingFor(objectId: string): PendingCertify | null {
59
+ return pending.value[objectId] ?? null
60
+ }
61
+ /** The first pending (certifiable) copy in a group, if any. */
62
+ function groupPendingCopy(g: BlobGroup): OwnedBlob | null {
63
+ return g.copies.find((c) => !c.certified && pending.value[c.objectId]) ?? null
64
+ }
65
+ /** Status shown in the Certified column for a group. */
66
+ function groupCertStatus(g: BlobGroup): 'certified' | 'pending' | 'none' {
67
+ if (g.anyCertified) return 'certified'
68
+ return groupPendingCopy(g) ? 'pending' : 'none'
69
+ }
70
+
71
+ function toggleExpand(blobId: string): void {
72
+ const next = new Set(expanded.value)
73
+ if (next.has(blobId)) next.delete(blobId)
74
+ else next.add(blobId)
75
+ expanded.value = next
76
+ }
77
+
78
+ function refresh(): Promise<void> {
79
+ return load(props.address, { force: true })
37
80
  }
38
81
 
39
82
  /** Reload the pending map from storage, dropping entries whose blob is already certified. */
@@ -44,7 +87,6 @@ function refreshPending(): void {
44
87
  }
45
88
  const key = pendingCertifyKey(NETWORK, props.address)
46
89
  const map = loadPendingCertifies(window.localStorage, key)
47
- // Housekeeping: a blob certified elsewhere no longer needs a stored certificate.
48
90
  for (const blob of blobs.value) {
49
91
  if (blob.certified && blob.objectId in map) {
50
92
  clearPendingCertify(window.localStorage, key, blob.objectId)
@@ -54,83 +96,117 @@ function refreshPending(): void {
54
96
  pending.value = map
55
97
  }
56
98
 
57
- async function certifyBlob(blob: OwnedBlob): Promise<void> {
58
- const entry = pendingFor(blob)
59
- if (!entry || !props.address) return
60
- certifying.value = blob.objectId
61
- certifyStatus.value = { ...certifyStatus.value, [blob.objectId]: 'Building transaction…' }
99
+ // ── Extend ──────────────────────────────────────────────────────────────────
100
+ let extendReq: Record<string, number> = {}
101
+
102
+ // User picked an amount → clamp and (re)price it. We estimate only on interaction, not for every row
103
+ // on load, to avoid spinning up a Walrus client per blob.
104
+ function setExtendAmount(blob: OwnedBlob, value: number | string): void {
105
+ const max = maxAddable(blob.endEpoch)
106
+ const v = Math.min(max, Math.max(1, Math.floor(Number(value) || 1)))
107
+ extendAmount.value = { ...extendAmount.value, [blob.objectId]: v }
108
+ void estimateExtend(blob, v)
109
+ }
110
+ async function estimateExtend(blob: OwnedBlob, epochs: number): Promise<void> {
111
+ const id = blob.objectId
112
+ const token = (extendReq[id] = (extendReq[id] ?? 0) + 1)
62
113
  try {
63
- const { createWalrusClient, certifyBlobTransaction } = await import('@meddleware/walrus-client')
114
+ const { createWalrusClient, estimateStorageCost } = await import('@meddleware/walrus-client')
64
115
  const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
65
- const tx = certifyBlobTransaction(walrusClient, {
66
- blobId: entry.blobId,
67
- blobObjectId: entry.blobObjectId,
68
- certificate: entry.certificate,
69
- deletable: entry.deletable,
70
- })
116
+ // Extend adds storage only (no one-time write cost), so price the `storageCost` component.
117
+ const cost = await estimateStorageCost(walrusClient, blob.size, epochs)
118
+ if (extendReq[id] === token) {
119
+ extendCostFrost.value = { ...extendCostFrost.value, [id]: cost.storageCost }
120
+ }
121
+ } catch {
122
+ if (extendReq[id] === token) extendCostFrost.value = { ...extendCostFrost.value, [id]: null }
123
+ }
124
+ }
125
+
126
+ async function extendBlob(blob: OwnedBlob): Promise<void> {
127
+ const epochs = extendAmount.value[blob.objectId]
128
+ if (!epochs || busy.value) return
129
+ busy.value = blob.objectId
130
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Building transaction…' }
131
+ try {
132
+ const { createWalrusClient, extendBlobLifetimeTransaction } = await import('@meddleware/walrus-client')
133
+ const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
134
+ const tx = await extendBlobLifetimeTransaction(walrusClient, blob.objectId, { epochs })
71
135
  const executor = await props.buildExecutor()
72
- certifyStatus.value = { ...certifyStatus.value, [blob.objectId]: 'Approve in wallet…' }
136
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Approve in wallet…' }
73
137
  const { digest } = await executor.signAndExecute(tx)
74
138
  await executor.waitForTransaction(digest)
75
- clearPendingCertify(window.localStorage, pendingCertifyKey(NETWORK, props.address), blob.objectId)
76
- certifyStatus.value = { ...certifyStatus.value, [blob.objectId]: `Certified ✓ (${digest.slice(0, 8)}…)` }
77
- await refresh() // reflect certified = ✓
139
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Extended ✓ (${digest.slice(0, 8)}…)` }
140
+ await refresh()
78
141
  } catch (e) {
79
- certifyStatus.value = {
80
- ...certifyStatus.value,
81
- [blob.objectId]: `Failed: ${e instanceof Error ? e.message : String(e)}`,
82
- }
142
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Failed: ${msg(e)}` }
83
143
  } finally {
84
- certifying.value = null
144
+ busy.value = null
85
145
  }
86
146
  }
87
147
 
88
- const EXPIRY_WARN_EPOCHS = 10
89
- const EPOCHS_PER_DAY = 1 / 0.038 // ~1 Walrus epoch ≈ 38 minutes on testnet
90
-
91
- function epochsToApproxDays(epochs: number): string {
92
- const days = Math.round(epochs * EPOCHS_PER_DAY)
93
- if (days <= 0) return 'expired'
94
- if (days < 2) return `${days} day`
95
- return `${days} days`
96
- }
97
-
98
- /** Force a fresh fetch (Refresh button + after an on-chain-affecting action). */
99
- function refresh(): Promise<void> {
100
- return load(props.address, { force: true })
101
- }
102
-
103
- async function extendBlob(blob: OwnedBlob): Promise<void> {
104
- extending.value = blob.objectId
105
- extendStatus.value = { ...extendStatus.value, [blob.objectId]: 'Building transaction…' }
148
+ // ── Certify (resume a pending upload from its stored certificate) ─────────────
149
+ async function certifyBlob(blob: OwnedBlob): Promise<void> {
150
+ const entry = pendingFor(blob.objectId)
151
+ if (!entry || !props.address || busy.value) return
152
+ busy.value = blob.objectId
153
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Building transaction…' }
106
154
  try {
107
- const { createWalrusClient, extendBlobLifetimeTransaction } = await import('@meddleware/walrus-client')
155
+ const { createWalrusClient, certifyBlobTransaction } = await import('@meddleware/walrus-client')
108
156
  const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
109
- const tx = await extendBlobLifetimeTransaction(walrusClient, blob.objectId, { epochs: 10 })
157
+ const tx = certifyBlobTransaction(walrusClient, {
158
+ blobId: entry.blobId,
159
+ blobObjectId: entry.blobObjectId,
160
+ certificate: entry.certificate,
161
+ deletable: entry.deletable,
162
+ })
110
163
  const executor = await props.buildExecutor()
111
- extendStatus.value = { ...extendStatus.value, [blob.objectId]: 'Approve in wallet…' }
164
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Approve in wallet…' }
112
165
  const { digest } = await executor.signAndExecute(tx)
113
166
  await executor.waitForTransaction(digest)
114
- extendStatus.value = { ...extendStatus.value, [blob.objectId]: `Extended ✓ (${digest.slice(0, 8)}…)` }
115
- // Refresh the list so the new endEpoch is visible.
167
+ clearPendingCertify(window.localStorage, pendingCertifyKey(NETWORK, props.address), blob.objectId)
168
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Certified (${digest.slice(0, 8)}…)` }
116
169
  await refresh()
117
170
  } catch (e) {
118
- extendStatus.value = {
119
- ...extendStatus.value,
120
- [blob.objectId]: `Failed: ${e instanceof Error ? e.message : String(e)}`,
121
- }
171
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Failed: ${msg(e)}` }
122
172
  } finally {
123
- extending.value = null
173
+ busy.value = null
124
174
  }
125
175
  }
126
176
 
127
- // Load on first open (fixes "nothing shows until Refresh") and whenever the address changes.
128
- // The composable no-ops when the list is already cached for this address, so re-mounting is cheap.
177
+ // Load on first open and whenever the address changes; re-derive pending on list/address changes.
129
178
  onMounted(() => void load(props.address))
130
179
  watch(() => props.address, (addr) => void load(addr))
131
- // Re-derive pending certifications whenever the list refreshes or the account changes.
132
180
  watch(blobs, () => refreshPending())
133
181
  watch(() => props.address, () => refreshPending())
182
+
183
+ // Seed each group's extend amount with a sensible default (+10, clamped) without pricing it — the
184
+ // estimate is fetched on the first user interaction.
185
+ watch(
186
+ [groups, currentEpoch],
187
+ () => {
188
+ for (const g of groups.value) {
189
+ const b = g.representative
190
+ const max = maxAddable(b.endEpoch)
191
+ if (max > 0 && extendAmount.value[b.objectId] === undefined) {
192
+ extendAmount.value = { ...extendAmount.value, [b.objectId]: Math.min(10, max) }
193
+ }
194
+ }
195
+ },
196
+ { immediate: true },
197
+ )
198
+
199
+ // Highlight + expand the group routed from the duplicate-upload dialog.
200
+ watch(
201
+ () => props.highlightBlobId,
202
+ async (blobId) => {
203
+ if (!blobId) return
204
+ expanded.value = new Set(expanded.value).add(blobId)
205
+ await nextTick()
206
+ document.getElementById(`blob-row-${blobId}`)?.scrollIntoView({ block: 'center', behavior: 'smooth' })
207
+ },
208
+ { immediate: true },
209
+ )
134
210
  </script>
135
211
 
136
212
  <template>
@@ -144,9 +220,9 @@ watch(() => props.address, () => refreshPending())
144
220
 
145
221
  <p v-if="!address" class="hint">Connect your wallet to list your Walrus blobs.</p>
146
222
  <p v-else-if="error" class="err">{{ error }}</p>
147
- <p v-else-if="!loading && blobs.length === 0" class="hint">No Walrus blobs found for this address.</p>
223
+ <p v-else-if="!loading && groups.length === 0" class="hint">No Walrus blobs found for this address.</p>
148
224
 
149
- <table v-if="blobs.length" class="blob-table">
225
+ <table v-if="groups.length" class="blob-table">
150
226
  <thead>
151
227
  <tr>
152
228
  <th>Blob ID</th>
@@ -157,62 +233,101 @@ watch(() => props.address, () => refreshPending())
157
233
  </tr>
158
234
  </thead>
159
235
  <tbody>
160
- <tr
161
- v-for="blob in blobs"
162
- :key="blob.objectId"
163
- :class="{ warn: blob.endEpoch - currentEpoch < EXPIRY_WARN_EPOCHS }"
164
- >
165
- <td>
166
- <CopyableAddress :address="blob.blobId" label="Copy blob ID">
167
- <ExplorerLink
168
- :href="walruscanBlobUrl(NETWORK, blob.blobId)"
169
- :value="blob.blobId"
170
- :chars="[8, 6]"
171
- />
172
- </CopyableAddress>
173
- </td>
174
- <td>{{ (blob.size / 1024).toFixed(1) }} KB</td>
175
- <td>
176
- epoch {{ blob.endEpoch }}
177
- <span class="approx">(≈{{ epochsToApproxDays(blob.endEpoch - currentEpoch) }})</span>
178
- </td>
179
- <td>
180
- <span v-if="blob.certified">✓</span>
181
- <span v-else-if="pendingFor(blob)" class="pending-badge" title="Uploaded but not certified">
182
- pending
183
- </span>
184
- <span v-else>—</span>
185
- </td>
186
- <td class="actions">
187
- <!-- Certify: the blob was uploaded + paid for but not certified; finish it (no re-upload). -->
188
- <template v-if="pendingFor(blob)">
189
- <span v-if="certifyStatus[blob.objectId]" class="ext-status">
190
- {{ certifyStatus[blob.objectId] }}
236
+ <template v-for="g in groups" :key="g.blobId">
237
+ <tr
238
+ :id="`blob-row-${g.blobId}`"
239
+ :class="{ warn: g.maxEndEpoch - currentEpoch < 10, highlight: g.blobId === highlightBlobId }"
240
+ >
241
+ <td>
242
+ <CopyableAddress :address="g.blobId" label="Copy blob ID">
243
+ <ExplorerLink :href="walruscanBlobUrl(NETWORK, g.blobId)" :value="g.blobId" :chars="[8, 6]" />
244
+ </CopyableAddress>
245
+ <button
246
+ v-if="g.copies.length > 1"
247
+ type="button"
248
+ class="copies-toggle"
249
+ @click="toggleExpand(g.blobId)"
250
+ >
251
+ {{ expanded.has(g.blobId) ? '▾' : '▸' }} {{ g.copies.length }} copies
252
+ </button>
253
+ </td>
254
+ <td>{{ (g.size / 1024).toFixed(1) }} KB</td>
255
+ <td>{{ expiryLabel(g.maxEndEpoch, currentEpoch) }}</td>
256
+ <td>
257
+ <span v-if="groupCertStatus(g) === 'certified'">✓</span>
258
+ <span v-else-if="groupCertStatus(g) === 'pending'" class="pending-badge" title="Uploaded but not certified">pending</span>
259
+ <span v-else>—</span>
260
+ </td>
261
+ <td class="actions">
262
+ <span v-if="actionStatus[g.representative.objectId]" class="act-status">
263
+ {{ actionStatus[g.representative.objectId] }}
191
264
  </span>
265
+ <template v-else>
266
+ <button
267
+ v-if="groupPendingCopy(g)"
268
+ type="button"
269
+ class="certify-btn"
270
+ :disabled="!!busy"
271
+ @click="certifyBlob(groupPendingCopy(g)!)"
272
+ >
273
+ Certify
274
+ </button>
275
+ <span v-if="maxAddable(g.representative.endEpoch) === 0" class="at-max">at max lifetime</span>
276
+ <span v-else class="extend">
277
+ <input
278
+ type="number"
279
+ min="1"
280
+ :max="maxAddable(g.representative.endEpoch)"
281
+ :value="extendAmount[g.representative.objectId]"
282
+ aria-label="Epochs to add"
283
+ @input="setExtendAmount(g.representative, ($event.target as HTMLInputElement).value)"
284
+ />
285
+ <button type="button" class="preset" @click="setExtendAmount(g.representative, 10)">+10</button>
286
+ <button type="button" class="preset" @click="setExtendAmount(g.representative, 25)">+25</button>
287
+ <button
288
+ type="button"
289
+ class="preset"
290
+ @click="setExtendAmount(g.representative, maxAddable(g.representative.endEpoch))"
291
+ >
292
+ Max
293
+ </button>
294
+ <button type="button" :disabled="!!busy" @click="extendBlob(g.representative)">Extend</button>
295
+ <span v-if="extendCostFrost[g.representative.objectId] != null" class="est">
296
+ ≈{{ toWal(extendCostFrost[g.representative.objectId]!) }} WAL
297
+ </span>
298
+ </span>
299
+ </template>
300
+ </td>
301
+ </tr>
302
+
303
+ <!-- Per-copy detail for grouped duplicates: certify a specific pending copy. -->
304
+ <tr v-for="c in (expanded.has(g.blobId) ? g.copies : [])" :key="c.objectId" class="copy-row">
305
+ <td class="copy-obj">
306
+ <CopyableAddress :address="c.objectId" label="Copy object ID">
307
+ <span class="mono">{{ c.objectId.slice(0, 10) }}…</span>
308
+ </CopyableAddress>
309
+ </td>
310
+ <td></td>
311
+ <td>{{ expiryLabel(c.endEpoch, currentEpoch) }}</td>
312
+ <td>
313
+ <span v-if="c.certified">✓</span>
314
+ <span v-else-if="pendingFor(c.objectId)" class="pending-badge">pending</span>
315
+ <span v-else>—</span>
316
+ </td>
317
+ <td class="actions">
318
+ <span v-if="actionStatus[c.objectId]" class="act-status">{{ actionStatus[c.objectId] }}</span>
192
319
  <button
193
- v-else
320
+ v-else-if="pendingFor(c.objectId)"
194
321
  type="button"
195
322
  class="certify-btn"
196
- :disabled="certifying === blob.objectId"
197
- @click="certifyBlob(blob)"
323
+ :disabled="!!busy"
324
+ @click="certifyBlob(c)"
198
325
  >
199
326
  Certify
200
327
  </button>
201
- </template>
202
-
203
- <span v-if="extendStatus[blob.objectId]" class="ext-status">
204
- {{ extendStatus[blob.objectId] }}
205
- </span>
206
- <button
207
- v-else
208
- type="button"
209
- :disabled="extending === blob.objectId"
210
- @click="extendBlob(blob)"
211
- >
212
- +10 epochs
213
- </button>
214
- </td>
215
- </tr>
328
+ </td>
329
+ </tr>
330
+ </template>
216
331
  </tbody>
217
332
  </table>
218
333
  </section>
@@ -250,6 +365,7 @@ watch(() => props.address, () => refreshPending())
250
365
  text-align: left;
251
366
  padding: 0.4rem 0.6rem;
252
367
  border-bottom: 1px solid var(--mw-color-border, #ddd);
368
+ vertical-align: top;
253
369
  }
254
370
  .blob-table th {
255
371
  font-weight: 600;
@@ -258,22 +374,49 @@ watch(() => props.address, () => refreshPending())
258
374
  .blob-table tr.warn td {
259
375
  background: color-mix(in srgb, var(--mw-color-warning, #f90) 8%, transparent);
260
376
  }
377
+ .blob-table tr.highlight td {
378
+ background: color-mix(in srgb, var(--accent, #6366f1) 14%, transparent);
379
+ }
380
+ .copy-row td {
381
+ background: color-mix(in srgb, var(--mw-color-text-muted, #888) 6%, transparent);
382
+ font-size: 0.82rem;
383
+ }
384
+ .copies-toggle {
385
+ display: inline-block;
386
+ margin-top: 0.25rem;
387
+ font-size: 0.78rem;
388
+ padding: 0.05rem 0.4rem;
389
+ }
261
390
  .mono {
262
391
  font-family: monospace;
263
392
  }
264
- .approx {
265
- font-size: 0.8em;
393
+ .actions {
394
+ white-space: nowrap;
395
+ }
396
+ .extend {
397
+ display: inline-flex;
398
+ align-items: center;
399
+ gap: 0.3rem;
400
+ flex-wrap: wrap;
401
+ }
402
+ .extend input {
403
+ width: 4rem;
404
+ }
405
+ .preset {
406
+ font-size: 0.75rem;
407
+ padding: 0.1rem 0.4rem;
408
+ }
409
+ .est {
410
+ font-size: 0.78rem;
266
411
  color: var(--mw-color-text-muted, #888);
267
412
  }
268
- .ext-status {
269
- font-size: 0.85rem;
413
+ .at-max {
414
+ font-size: 0.8rem;
270
415
  color: var(--mw-color-text-muted, #888);
271
416
  }
272
- .actions {
273
- display: flex;
274
- flex-wrap: wrap;
275
- gap: 0.4rem;
276
- align-items: center;
417
+ .act-status {
418
+ font-size: 0.85rem;
419
+ color: var(--mw-color-text-muted, #888);
277
420
  }
278
421
  .certify-btn {
279
422
  border-color: var(--accent, #6366f1);
@@ -8,9 +8,8 @@ import {
8
8
  WalrusUpload,
9
9
  AccessGateCta,
10
10
  useAccessGate,
11
- MAX_SINGLE_RESERVATION_EPOCHS,
12
11
  } from '@meddleware/walrus-relay'
13
- import type { UploadResult, UploadProgress } from '@meddleware/walrus-relay'
12
+ import type { UploadResult, UploadProgress, ExistingCopy } from '@meddleware/walrus-relay'
14
13
  import { CopyableAddress, ExplorerLink, UiNotice, suiExplorerUrl } from '@meddleware/ui'
15
14
  // Lightweight URL import — just the wasm asset URL (does not pull the walrus client).
16
15
  import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
@@ -29,6 +28,7 @@ import {
29
28
  pendingCertifyKey,
30
29
  savePendingCertify,
31
30
  clearPendingCertify,
31
+ loadPendingCertifies,
32
32
  } from '../certify-resume.js'
33
33
  import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
34
34
  import MyBlobs from './MyBlobs.vue'
@@ -73,7 +73,12 @@ async function onPurchase(): Promise<void> {
73
73
  // upload-flow.ts). Reusing a prior registration is what produced "the received transaction is too old".
74
74
  async function performUpload(
75
75
  bytes: Uint8Array,
76
- opts: { relayHost: string; onStatus: (s: string | UploadProgress) => void },
76
+ opts: {
77
+ relayHost: string
78
+ epochs: number
79
+ force?: boolean
80
+ onStatus: (s: string | UploadProgress) => void
81
+ },
77
82
  ): Promise<UploadResult> {
78
83
  if (!account.value) throw new Error('Connect your wallet first.')
79
84
  const executor = await buildExecutor()
@@ -115,7 +120,9 @@ async function performUpload(
115
120
  address,
116
121
  wasmUrl: walrusWasmUrl,
117
122
  maxTipMist: uploadRelayMaxTipMist(),
118
- epochs: MAX_SINGLE_RESERVATION_EPOCHS,
123
+ epochs: opts.epochs,
124
+ force: opts.force,
125
+ findExistingCopy,
119
126
  executor,
120
127
  suiClient: getSuiClient(),
121
128
  authToken,
@@ -151,6 +158,57 @@ async function performUpload(
151
158
  }
152
159
  }
153
160
 
161
+ // Precheck (before paying to register): does the wallet already own this exact blob? Returns a
162
+ // `certified` match (offer Extend) or a `pending` one — uncertified but with a saved certificate
163
+ // (offer Certify). Best-effort: any failure returns null so the upload simply proceeds.
164
+ async function findExistingCopy(blobId: string): Promise<ExistingCopy | null> {
165
+ const address = account.value?.address
166
+ if (!address) return null
167
+ try {
168
+ const { createWalrusClient, fetchOwnedWalrusBlobs } = await import('@meddleware/walrus-client')
169
+ const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
170
+ const [sys, owned] = await Promise.all([
171
+ walrusClient.walrus.systemState(),
172
+ fetchOwnedWalrusBlobs(getSuiClient(), walrusClient, address),
173
+ ])
174
+ const currentEpoch = Number(sys.committee.epoch)
175
+ const copies = owned.filter((b) => b.blobId === blobId && b.endEpoch > currentEpoch)
176
+ const certified = copies.find((b) => b.certified)
177
+ if (certified) {
178
+ return { kind: 'certified', blobId, objectId: certified.objectId, endEpoch: certified.endEpoch }
179
+ }
180
+ const store = loadPendingCertifies(window.localStorage, pendingCertifyKey(NETWORK, address))
181
+ const pending = copies.find((b) => !b.certified && b.objectId in store)
182
+ if (pending) {
183
+ return { kind: 'pending', blobId, objectId: pending.objectId, endEpoch: pending.endEpoch }
184
+ }
185
+ return null
186
+ } catch {
187
+ return null
188
+ }
189
+ }
190
+
191
+ // Injected into the upload widget so it can price a chosen reservation duration (storage is WAL,
192
+ // billed per size × epochs). Returns total cost in FROST, or null on error.
193
+ async function estimateUploadStorageCost(sizeBytes: number, epochs: number): Promise<bigint | null> {
194
+ try {
195
+ const { createWalrusClient, estimateStorageCost } = await import('@meddleware/walrus-client')
196
+ const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
197
+ const cost = await estimateStorageCost(walrusClient, sizeBytes, epochs)
198
+ return cost.totalCost
199
+ } catch {
200
+ return null
201
+ }
202
+ }
203
+
204
+ // The user declined a duplicate upload and chose to manage the existing copy: jump to My Blobs and
205
+ // highlight it (the grouped row exposes Extend for certified, Certify for pending).
206
+ const highlightBlobId = ref<string | null>(null)
207
+ function onManageExisting(existing: ExistingCopy): void {
208
+ highlightBlobId.value = existing.blobId
209
+ activeTab.value = 'blobs'
210
+ }
211
+
154
212
  const ownedBlobs = useOwnedBlobs()
155
213
 
156
214
  function onUploaded(r: UploadResult): void {
@@ -231,8 +289,10 @@ function onSettled(): void {
231
289
  :connected="!!account"
232
290
  :access="{ gateConfigured: gateState.gateConfigured, hasAccess: gateState.hasAccess }"
233
291
  :perform-upload="performUpload"
292
+ :estimate-storage-cost="estimateUploadStorageCost"
234
293
  @uploaded="onUploaded"
235
294
  @settled="onSettled"
295
+ @manage-existing="onManageExisting"
236
296
  />
237
297
 
238
298
  <section v-if="result" class="result">
@@ -269,6 +329,7 @@ function onSettled(): void {
269
329
  v-if="activeTab === 'blobs'"
270
330
  :address="account?.address ?? null"
271
331
  :build-executor="() => buildExecutor()"
332
+ :highlight-blob-id="highlightBlobId"
272
333
  />
273
334
  </WalletGuard>
274
335
  </div>
@@ -11,8 +11,8 @@
11
11
  // can't be reused across attempts — reusing a prior/discovered registration (localStorage or on-chain
12
12
  // discovery) hands the relay an old tx with a non-matching nonce. Each attempt re-encodes (free) and
13
13
  // registers fresh so the tip+nonce the relay verifies is always recent.
14
- import type { UploadResult, UploadProgress } from '@meddleware/walrus-relay'
15
- import { attachCertifyRetry } from '@meddleware/walrus-relay'
14
+ import type { UploadResult, UploadProgress, ExistingCopy } from '@meddleware/walrus-relay'
15
+ import { attachCertifyRetry, attachDuplicateExisting } from '@meddleware/walrus-relay'
16
16
 
17
17
  /** Minimal transaction executor — the structural subset App.vue's wallet executor already provides. */
18
18
  export interface UploadExecutor {
@@ -35,7 +35,8 @@ interface BuiltTx {
35
35
  export interface BlobUploadFlow {
36
36
  // Encoding is deterministic from the content and costs no gas; it also mints the per-attempt relay
37
37
  // `nonce` committed by the register tip, so it must precede register/upload on this flow instance.
38
- encode(): Promise<void>
38
+ // Returns the content-derived `blobId`, used to check for an existing owned copy before registering.
39
+ encode(): Promise<{ blobId: string }>
39
40
  register(opts: { owner: string; epochs: number; deletable: boolean }): BuiltTx
40
41
  // `digest` = the register transaction digest produced by the register tx executed this attempt.
41
42
  // Returns the SDK "uploaded" step, including the on-chain `blobObjectId` and the base64
@@ -61,6 +62,17 @@ export interface RunBlobUploadDeps {
61
62
  maxTipMist: number
62
63
  /** Blob storage reservation length in epochs. */
63
64
  epochs: number
65
+ /**
66
+ * When true, skip the existing-copy precheck and register a fresh copy unconditionally (the user
67
+ * chose "Upload a new copy" after being warned).
68
+ */
69
+ force?: boolean
70
+ /**
71
+ * Precheck for an existing owned copy of this content (by `blobId`, computed from `encode()`).
72
+ * When it returns a match and `force` is not set, the upload aborts BEFORE registering (no
73
+ * reservation, no tip) and throws an error carrying the match so the UI can offer Extend/Certify.
74
+ */
75
+ findExistingCopy?: (blobId: string) => Promise<ExistingCopy | null>
64
76
  executor: UploadExecutor
65
77
  /** A Sui client used to `build()` the register/certify transactions. */
66
78
  suiClient: unknown
@@ -107,7 +119,19 @@ export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResu
107
119
  const flow = createBlobUploadFlow(client, deps.bytes)
108
120
 
109
121
  deps.onStatus({ step: 'encode', detail: 'Encoding…' })
110
- await flow.encode()
122
+ const { blobId } = await flow.encode()
123
+
124
+ // Before paying to register, check whether the wallet already owns this exact blob. If so, abort
125
+ // and surface the match so the UI can offer Extend (certified) / Certify (pending) instead of
126
+ // creating a wasteful duplicate. `force` (chosen "Upload a new copy") bypasses this.
127
+ if (!deps.force && deps.findExistingCopy) {
128
+ const existing = await deps.findExistingCopy(blobId)
129
+ if (existing) {
130
+ const err = new Error('Blob already stored on-chain')
131
+ attachDuplicateExisting(err, existing)
132
+ throw err
133
+ }
134
+ }
111
135
 
112
136
  deps.onStatus({ step: 'register', detail: 'Registering blob (approve in wallet)…' })
113
137
  const regTx = flow.register({ owner: deps.address, epochs: deps.epochs, deletable: false })