@meddleware/walrus-ui 0.1.26 → 0.1.28

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.28",
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.12",
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, formatCoinAmount } 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,61 @@ 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
+
49
+ function maxAddable(endEpoch: number): number {
50
+ return maxExtendableEpochs(endEpoch, currentEpoch.value, MAX)
51
+ }
52
+
53
+ /** The stored certificate for an uncertified copy, if we can certify it here. */
54
+ function pendingFor(objectId: string): PendingCertify | null {
55
+ return pending.value[objectId] ?? null
56
+ }
57
+ /** The first pending (certifiable) copy in a group, if any. */
58
+ function groupPendingCopy(g: BlobGroup): OwnedBlob | null {
59
+ return g.copies.find((c) => !c.certified && pending.value[c.objectId]) ?? null
60
+ }
61
+ /** Status shown in the Certified column for a group. */
62
+ function groupCertStatus(g: BlobGroup): 'certified' | 'pending' | 'none' {
63
+ if (g.anyCertified) return 'certified'
64
+ return groupPendingCopy(g) ? 'pending' : 'none'
65
+ }
66
+
67
+ function toggleExpand(blobId: string): void {
68
+ const next = new Set(expanded.value)
69
+ if (next.has(blobId)) next.delete(blobId)
70
+ else next.add(blobId)
71
+ expanded.value = next
72
+ }
73
+
74
+ function refresh(): Promise<void> {
75
+ return load(props.address, { force: true })
37
76
  }
38
77
 
39
78
  /** Reload the pending map from storage, dropping entries whose blob is already certified. */
@@ -44,7 +83,6 @@ function refreshPending(): void {
44
83
  }
45
84
  const key = pendingCertifyKey(NETWORK, props.address)
46
85
  const map = loadPendingCertifies(window.localStorage, key)
47
- // Housekeeping: a blob certified elsewhere no longer needs a stored certificate.
48
86
  for (const blob of blobs.value) {
49
87
  if (blob.certified && blob.objectId in map) {
50
88
  clearPendingCertify(window.localStorage, key, blob.objectId)
@@ -54,83 +92,117 @@ function refreshPending(): void {
54
92
  pending.value = map
55
93
  }
56
94
 
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…' }
95
+ // ── Extend ──────────────────────────────────────────────────────────────────
96
+ let extendReq: Record<string, number> = {}
97
+
98
+ // User picked an amount → clamp and (re)price it. We estimate only on interaction, not for every row
99
+ // on load, to avoid spinning up a Walrus client per blob.
100
+ function setExtendAmount(blob: OwnedBlob, value: number | string): void {
101
+ const max = maxAddable(blob.endEpoch)
102
+ const v = Math.min(max, Math.max(1, Math.floor(Number(value) || 1)))
103
+ extendAmount.value = { ...extendAmount.value, [blob.objectId]: v }
104
+ void estimateExtend(blob, v)
105
+ }
106
+ async function estimateExtend(blob: OwnedBlob, epochs: number): Promise<void> {
107
+ const id = blob.objectId
108
+ const token = (extendReq[id] = (extendReq[id] ?? 0) + 1)
62
109
  try {
63
- const { createWalrusClient, certifyBlobTransaction } = await import('@meddleware/walrus-client')
110
+ const { createWalrusClient, estimateStorageCost } = await import('@meddleware/walrus-client')
64
111
  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
- })
112
+ // Extend adds storage only (no one-time write cost), so price the `storageCost` component.
113
+ const cost = await estimateStorageCost(walrusClient, blob.size, epochs)
114
+ if (extendReq[id] === token) {
115
+ extendCostFrost.value = { ...extendCostFrost.value, [id]: cost.storageCost }
116
+ }
117
+ } catch {
118
+ if (extendReq[id] === token) extendCostFrost.value = { ...extendCostFrost.value, [id]: null }
119
+ }
120
+ }
121
+
122
+ async function extendBlob(blob: OwnedBlob): Promise<void> {
123
+ const epochs = extendAmount.value[blob.objectId]
124
+ if (!epochs || busy.value) return
125
+ busy.value = blob.objectId
126
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Building transaction…' }
127
+ try {
128
+ const { createWalrusClient, extendBlobLifetimeTransaction } = await import('@meddleware/walrus-client')
129
+ const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
130
+ const tx = await extendBlobLifetimeTransaction(walrusClient, blob.objectId, { epochs })
71
131
  const executor = await props.buildExecutor()
72
- certifyStatus.value = { ...certifyStatus.value, [blob.objectId]: 'Approve in wallet…' }
132
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Approve in wallet…' }
73
133
  const { digest } = await executor.signAndExecute(tx)
74
134
  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 = ✓
135
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Extended ✓ (${digest.slice(0, 8)}…)` }
136
+ await refresh()
78
137
  } catch (e) {
79
- certifyStatus.value = {
80
- ...certifyStatus.value,
81
- [blob.objectId]: `Failed: ${e instanceof Error ? e.message : String(e)}`,
82
- }
138
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Failed: ${msg(e)}` }
83
139
  } finally {
84
- certifying.value = null
140
+ busy.value = null
85
141
  }
86
142
  }
87
143
 
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…' }
144
+ // ── Certify (resume a pending upload from its stored certificate) ─────────────
145
+ async function certifyBlob(blob: OwnedBlob): Promise<void> {
146
+ const entry = pendingFor(blob.objectId)
147
+ if (!entry || !props.address || busy.value) return
148
+ busy.value = blob.objectId
149
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Building transaction…' }
106
150
  try {
107
- const { createWalrusClient, extendBlobLifetimeTransaction } = await import('@meddleware/walrus-client')
151
+ const { createWalrusClient, certifyBlobTransaction } = await import('@meddleware/walrus-client')
108
152
  const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
109
- const tx = await extendBlobLifetimeTransaction(walrusClient, blob.objectId, { epochs: 10 })
153
+ const tx = certifyBlobTransaction(walrusClient, {
154
+ blobId: entry.blobId,
155
+ blobObjectId: entry.blobObjectId,
156
+ certificate: entry.certificate,
157
+ deletable: entry.deletable,
158
+ })
110
159
  const executor = await props.buildExecutor()
111
- extendStatus.value = { ...extendStatus.value, [blob.objectId]: 'Approve in wallet…' }
160
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Approve in wallet…' }
112
161
  const { digest } = await executor.signAndExecute(tx)
113
162
  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.
163
+ clearPendingCertify(window.localStorage, pendingCertifyKey(NETWORK, props.address), blob.objectId)
164
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Certified (${digest.slice(0, 8)}…)` }
116
165
  await refresh()
117
166
  } catch (e) {
118
- extendStatus.value = {
119
- ...extendStatus.value,
120
- [blob.objectId]: `Failed: ${e instanceof Error ? e.message : String(e)}`,
121
- }
167
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Failed: ${msg(e)}` }
122
168
  } finally {
123
- extending.value = null
169
+ busy.value = null
124
170
  }
125
171
  }
126
172
 
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.
173
+ // Load on first open and whenever the address changes; re-derive pending on list/address changes.
129
174
  onMounted(() => void load(props.address))
130
175
  watch(() => props.address, (addr) => void load(addr))
131
- // Re-derive pending certifications whenever the list refreshes or the account changes.
132
176
  watch(blobs, () => refreshPending())
133
177
  watch(() => props.address, () => refreshPending())
178
+
179
+ // Seed each group's extend amount with a sensible default (+10, clamped) without pricing it — the
180
+ // estimate is fetched on the first user interaction.
181
+ watch(
182
+ [groups, currentEpoch],
183
+ () => {
184
+ for (const g of groups.value) {
185
+ const b = g.representative
186
+ const max = maxAddable(b.endEpoch)
187
+ if (max > 0 && extendAmount.value[b.objectId] === undefined) {
188
+ extendAmount.value = { ...extendAmount.value, [b.objectId]: Math.min(10, max) }
189
+ }
190
+ }
191
+ },
192
+ { immediate: true },
193
+ )
194
+
195
+ // Highlight + expand the group routed from the duplicate-upload dialog.
196
+ watch(
197
+ () => props.highlightBlobId,
198
+ async (blobId) => {
199
+ if (!blobId) return
200
+ expanded.value = new Set(expanded.value).add(blobId)
201
+ await nextTick()
202
+ document.getElementById(`blob-row-${blobId}`)?.scrollIntoView({ block: 'center', behavior: 'smooth' })
203
+ },
204
+ { immediate: true },
205
+ )
134
206
  </script>
135
207
 
136
208
  <template>
@@ -144,9 +216,9 @@ watch(() => props.address, () => refreshPending())
144
216
 
145
217
  <p v-if="!address" class="hint">Connect your wallet to list your Walrus blobs.</p>
146
218
  <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>
219
+ <p v-else-if="!loading && groups.length === 0" class="hint">No Walrus blobs found for this address.</p>
148
220
 
149
- <table v-if="blobs.length" class="blob-table">
221
+ <table v-if="groups.length" class="blob-table">
150
222
  <thead>
151
223
  <tr>
152
224
  <th>Blob ID</th>
@@ -157,62 +229,101 @@ watch(() => props.address, () => refreshPending())
157
229
  </tr>
158
230
  </thead>
159
231
  <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] }}
232
+ <template v-for="g in groups" :key="g.blobId">
233
+ <tr
234
+ :id="`blob-row-${g.blobId}`"
235
+ :class="{ warn: g.maxEndEpoch - currentEpoch < 10, highlight: g.blobId === highlightBlobId }"
236
+ >
237
+ <td>
238
+ <CopyableAddress :address="g.blobId" label="Copy blob ID">
239
+ <ExplorerLink :href="walruscanBlobUrl(NETWORK, g.blobId)" :value="g.blobId" :chars="[8, 6]" />
240
+ </CopyableAddress>
241
+ <button
242
+ v-if="g.copies.length > 1"
243
+ type="button"
244
+ class="copies-toggle"
245
+ @click="toggleExpand(g.blobId)"
246
+ >
247
+ {{ expanded.has(g.blobId) ? '▾' : '▸' }} {{ g.copies.length }} copies
248
+ </button>
249
+ </td>
250
+ <td>{{ (g.size / 1024).toFixed(1) }} KB</td>
251
+ <td>{{ expiryLabel(g.maxEndEpoch, currentEpoch) }}</td>
252
+ <td>
253
+ <span v-if="groupCertStatus(g) === 'certified'">✓</span>
254
+ <span v-else-if="groupCertStatus(g) === 'pending'" class="pending-badge" title="Uploaded but not certified">pending</span>
255
+ <span v-else>—</span>
256
+ </td>
257
+ <td class="actions">
258
+ <span v-if="actionStatus[g.representative.objectId]" class="act-status">
259
+ {{ actionStatus[g.representative.objectId] }}
191
260
  </span>
261
+ <template v-else>
262
+ <button
263
+ v-if="groupPendingCopy(g)"
264
+ type="button"
265
+ class="certify-btn"
266
+ :disabled="!!busy"
267
+ @click="certifyBlob(groupPendingCopy(g)!)"
268
+ >
269
+ Certify
270
+ </button>
271
+ <span v-if="maxAddable(g.representative.endEpoch) === 0" class="at-max">at max lifetime</span>
272
+ <span v-else class="extend">
273
+ <input
274
+ type="number"
275
+ min="1"
276
+ :max="maxAddable(g.representative.endEpoch)"
277
+ :value="extendAmount[g.representative.objectId]"
278
+ aria-label="Epochs to add"
279
+ @input="setExtendAmount(g.representative, ($event.target as HTMLInputElement).value)"
280
+ />
281
+ <button type="button" class="preset" @click="setExtendAmount(g.representative, 10)">+10</button>
282
+ <button type="button" class="preset" @click="setExtendAmount(g.representative, 25)">+25</button>
283
+ <button
284
+ type="button"
285
+ class="preset"
286
+ @click="setExtendAmount(g.representative, maxAddable(g.representative.endEpoch))"
287
+ >
288
+ Max
289
+ </button>
290
+ <button type="button" :disabled="!!busy" @click="extendBlob(g.representative)">Extend</button>
291
+ <span v-if="extendCostFrost[g.representative.objectId] != null" class="est">
292
+ ≈{{ formatCoinAmount(extendCostFrost[g.representative.objectId]!, 'WAL') }}
293
+ </span>
294
+ </span>
295
+ </template>
296
+ </td>
297
+ </tr>
298
+
299
+ <!-- Per-copy detail for grouped duplicates: certify a specific pending copy. -->
300
+ <tr v-for="c in (expanded.has(g.blobId) ? g.copies : [])" :key="c.objectId" class="copy-row">
301
+ <td class="copy-obj">
302
+ <CopyableAddress :address="c.objectId" label="Copy object ID">
303
+ <span class="mono">{{ c.objectId.slice(0, 10) }}…</span>
304
+ </CopyableAddress>
305
+ </td>
306
+ <td></td>
307
+ <td>{{ expiryLabel(c.endEpoch, currentEpoch) }}</td>
308
+ <td>
309
+ <span v-if="c.certified">✓</span>
310
+ <span v-else-if="pendingFor(c.objectId)" class="pending-badge">pending</span>
311
+ <span v-else>—</span>
312
+ </td>
313
+ <td class="actions">
314
+ <span v-if="actionStatus[c.objectId]" class="act-status">{{ actionStatus[c.objectId] }}</span>
192
315
  <button
193
- v-else
316
+ v-else-if="pendingFor(c.objectId)"
194
317
  type="button"
195
318
  class="certify-btn"
196
- :disabled="certifying === blob.objectId"
197
- @click="certifyBlob(blob)"
319
+ :disabled="!!busy"
320
+ @click="certifyBlob(c)"
198
321
  >
199
322
  Certify
200
323
  </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>
324
+ </td>
325
+ </tr>
326
+ </template>
216
327
  </tbody>
217
328
  </table>
218
329
  </section>
@@ -250,6 +361,7 @@ watch(() => props.address, () => refreshPending())
250
361
  text-align: left;
251
362
  padding: 0.4rem 0.6rem;
252
363
  border-bottom: 1px solid var(--mw-color-border, #ddd);
364
+ vertical-align: top;
253
365
  }
254
366
  .blob-table th {
255
367
  font-weight: 600;
@@ -258,22 +370,49 @@ watch(() => props.address, () => refreshPending())
258
370
  .blob-table tr.warn td {
259
371
  background: color-mix(in srgb, var(--mw-color-warning, #f90) 8%, transparent);
260
372
  }
373
+ .blob-table tr.highlight td {
374
+ background: color-mix(in srgb, var(--accent, #6366f1) 14%, transparent);
375
+ }
376
+ .copy-row td {
377
+ background: color-mix(in srgb, var(--mw-color-text-muted, #888) 6%, transparent);
378
+ font-size: 0.82rem;
379
+ }
380
+ .copies-toggle {
381
+ display: inline-block;
382
+ margin-top: 0.25rem;
383
+ font-size: 0.78rem;
384
+ padding: 0.05rem 0.4rem;
385
+ }
261
386
  .mono {
262
387
  font-family: monospace;
263
388
  }
264
- .approx {
265
- font-size: 0.8em;
389
+ .actions {
390
+ white-space: nowrap;
391
+ }
392
+ .extend {
393
+ display: inline-flex;
394
+ align-items: center;
395
+ gap: 0.3rem;
396
+ flex-wrap: wrap;
397
+ }
398
+ .extend input {
399
+ width: 4rem;
400
+ }
401
+ .preset {
402
+ font-size: 0.75rem;
403
+ padding: 0.1rem 0.4rem;
404
+ }
405
+ .est {
406
+ font-size: 0.78rem;
266
407
  color: var(--mw-color-text-muted, #888);
267
408
  }
268
- .ext-status {
269
- font-size: 0.85rem;
409
+ .at-max {
410
+ font-size: 0.8rem;
270
411
  color: var(--mw-color-text-muted, #888);
271
412
  }
272
- .actions {
273
- display: flex;
274
- flex-wrap: wrap;
275
- gap: 0.4rem;
276
- align-items: center;
413
+ .act-status {
414
+ font-size: 0.85rem;
415
+ color: var(--mw-color-text-muted, #888);
277
416
  }
278
417
  .certify-btn {
279
418
  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 })