@meddleware/walrus-ui 0.1.25 → 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.25",
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.9",
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
+ }
@@ -0,0 +1,66 @@
1
+ // Persistence for "uploaded but not yet certified" blobs, so certification can be completed later —
2
+ // after a tab switch OR a full page reload — from the My Blobs page, without re-uploading.
3
+ //
4
+ // A Walrus upload is register → upload → certify. Register + upload are paid and, with the tip relay,
5
+ // cannot be replayed (the register tx ages out — see upload-flow.ts). If certify doesn't happen (the
6
+ // user dismisses the prompt), the blob is registered + stored + paid but uncertified. The storage-node
7
+ // availability certificate returned by the upload step is all that's needed to certify, and it is not
8
+ // a secret, so we persist it keyed by (network, address). `@meddleware/walrus-client`'s
9
+ // `certifyBlobTransaction` accepts that base64 certificate directly — certify is a plain owner tx.
10
+
11
+ import type { StorageLike } from './access-resume.js'
12
+
13
+ /** A stored pending certification, keyed in the map by `blobObjectId`. */
14
+ export interface PendingCertify {
15
+ /** The Walrus blob id (for display / matching). */
16
+ blobId: string
17
+ /** The on-chain Blob object id — the map key and the object being certified. */
18
+ blobObjectId: string
19
+ /** Base64 availability certificate from the upload step (accepted directly by the SDK). */
20
+ certificate: string
21
+ /** How the blob was registered (our uploads are non-deletable). */
22
+ deletable: boolean
23
+ /** Epoch ms the entry was saved, for display / housekeeping. */
24
+ savedAt: number
25
+ }
26
+
27
+ /** Stable per-(network, address) key under which pending certifications are stored. */
28
+ export function pendingCertifyKey(network: string, address: string): string {
29
+ return `mw:walrus:pendingCertify:${network}:${address}`
30
+ }
31
+
32
+ /** Read the pending-certify map (keyed by blobObjectId); empty object if none/corrupt. */
33
+ export function loadPendingCertifies(
34
+ storage: StorageLike,
35
+ key: string,
36
+ ): Record<string, PendingCertify> {
37
+ const raw = storage.getItem(key)
38
+ if (!raw) return {}
39
+ try {
40
+ const v = JSON.parse(raw) as Record<string, PendingCertify>
41
+ return v && typeof v === 'object' ? v : {}
42
+ } catch {
43
+ return {}
44
+ }
45
+ }
46
+
47
+ /** Persist one pending certification (merged into the map by blobObjectId). */
48
+ export function savePendingCertify(
49
+ storage: StorageLike,
50
+ key: string,
51
+ entry: Omit<PendingCertify, 'savedAt'>,
52
+ ): void {
53
+ const map = loadPendingCertifies(storage, key)
54
+ map[entry.blobObjectId] = { ...entry, savedAt: Date.now() }
55
+ storage.setItem(key, JSON.stringify(map))
56
+ }
57
+
58
+ /** Remove a pending certification once the blob is certified (or found already certified). */
59
+ export function clearPendingCertify(storage: StorageLike, key: string, blobObjectId: string): void {
60
+ const map = loadPendingCertifies(storage, key)
61
+ if (blobObjectId in map) {
62
+ delete map[blobObjectId]
63
+ if (Object.keys(map).length === 0) storage.removeItem(key)
64
+ else storage.setItem(key, JSON.stringify(map))
65
+ }
66
+ }
@@ -1,67 +1,212 @@
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'
11
+ import {
12
+ pendingCertifyKey,
13
+ loadPendingCertifies,
14
+ clearPendingCertify,
15
+ type PendingCertify,
16
+ } from '../certify-resume.js'
9
17
 
10
18
  const props = defineProps<{
11
19
  /** Connected wallet address whose owned blobs to list; `null` when no wallet is connected. */
12
20
  address: string | null
13
- /** 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. */
14
22
  buildExecutor: () => Promise<Executor>
23
+ /** When set, the matching group is expanded + scrolled into view (from the duplicate-upload flow). */
24
+ highlightBlobId?: string | null
15
25
  }>()
16
26
 
17
27
  // Shared, session-persistent cache (survives tab switches and inline re-mounts).
18
28
  const { blobs, currentEpoch, loading, error, load } = useOwnedBlobs()
19
- const extending = ref<string | null>(null)
20
- const extendStatus = ref<Record<string, string>>({})
21
29
 
22
- const EXPIRY_WARN_EPOCHS = 10
23
- const EPOCHS_PER_DAY = 1 / 0.038 // ~1 Walrus epoch ≈ 38 minutes on testnet
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())
24
33
 
25
- function epochsToApproxDays(epochs: number): string {
26
- const days = Math.round(epochs * EPOCHS_PER_DAY)
27
- if (days <= 0) return 'expired'
28
- if (days < 2) return `${days} day`
29
- return `${days} days`
34
+ // Pending certifications (uploaded-but-not-certified copies we still hold a certificate for).
35
+ const pending = ref<Record<string, PendingCertify>>({})
36
+
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
30
76
  }
31
77
 
32
- /** Force a fresh fetch (Refresh button + after an on-chain-affecting action). */
33
78
  function refresh(): Promise<void> {
34
79
  return load(props.address, { force: true })
35
80
  }
36
81
 
82
+ /** Reload the pending map from storage, dropping entries whose blob is already certified. */
83
+ function refreshPending(): void {
84
+ if (!props.address) {
85
+ pending.value = {}
86
+ return
87
+ }
88
+ const key = pendingCertifyKey(NETWORK, props.address)
89
+ const map = loadPendingCertifies(window.localStorage, key)
90
+ for (const blob of blobs.value) {
91
+ if (blob.certified && blob.objectId in map) {
92
+ clearPendingCertify(window.localStorage, key, blob.objectId)
93
+ delete map[blob.objectId]
94
+ }
95
+ }
96
+ pending.value = map
97
+ }
98
+
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)
113
+ try {
114
+ const { createWalrusClient, estimateStorageCost } = await import('@meddleware/walrus-client')
115
+ const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
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
+
37
126
  async function extendBlob(blob: OwnedBlob): Promise<void> {
38
- extending.value = blob.objectId
39
- extendStatus.value = { ...extendStatus.value, [blob.objectId]: 'Building transaction…' }
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…' }
40
131
  try {
41
132
  const { createWalrusClient, extendBlobLifetimeTransaction } = await import('@meddleware/walrus-client')
42
133
  const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
43
- const tx = await extendBlobLifetimeTransaction(walrusClient, blob.objectId, { epochs: 10 })
134
+ const tx = await extendBlobLifetimeTransaction(walrusClient, blob.objectId, { epochs })
44
135
  const executor = await props.buildExecutor()
45
- extendStatus.value = { ...extendStatus.value, [blob.objectId]: 'Approve in wallet…' }
136
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Approve in wallet…' }
46
137
  const { digest } = await executor.signAndExecute(tx)
47
138
  await executor.waitForTransaction(digest)
48
- extendStatus.value = { ...extendStatus.value, [blob.objectId]: `Extended ✓ (${digest.slice(0, 8)}…)` }
49
- // Refresh the list so the new endEpoch is visible.
139
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Extended ✓ (${digest.slice(0, 8)}…)` }
50
140
  await refresh()
51
141
  } catch (e) {
52
- extendStatus.value = {
53
- ...extendStatus.value,
54
- [blob.objectId]: `Failed: ${e instanceof Error ? e.message : String(e)}`,
55
- }
142
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Failed: ${msg(e)}` }
56
143
  } finally {
57
- extending.value = null
144
+ busy.value = null
58
145
  }
59
146
  }
60
147
 
61
- // Load on first open (fixes "nothing shows until Refresh") and whenever the address changes.
62
- // The composable no-ops when the list is already cached for this address, so re-mounting is cheap.
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…' }
154
+ try {
155
+ const { createWalrusClient, certifyBlobTransaction } = await import('@meddleware/walrus-client')
156
+ const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
157
+ const tx = certifyBlobTransaction(walrusClient, {
158
+ blobId: entry.blobId,
159
+ blobObjectId: entry.blobObjectId,
160
+ certificate: entry.certificate,
161
+ deletable: entry.deletable,
162
+ })
163
+ const executor = await props.buildExecutor()
164
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Approve in wallet…' }
165
+ const { digest } = await executor.signAndExecute(tx)
166
+ await executor.waitForTransaction(digest)
167
+ clearPendingCertify(window.localStorage, pendingCertifyKey(NETWORK, props.address), blob.objectId)
168
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Certified ✓ (${digest.slice(0, 8)}…)` }
169
+ await refresh()
170
+ } catch (e) {
171
+ actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Failed: ${msg(e)}` }
172
+ } finally {
173
+ busy.value = null
174
+ }
175
+ }
176
+
177
+ // Load on first open and whenever the address changes; re-derive pending on list/address changes.
63
178
  onMounted(() => void load(props.address))
64
179
  watch(() => props.address, (addr) => void load(addr))
180
+ watch(blobs, () => refreshPending())
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
+ )
65
210
  </script>
66
211
 
67
212
  <template>
@@ -75,9 +220,9 @@ watch(() => props.address, (addr) => void load(addr))
75
220
 
76
221
  <p v-if="!address" class="hint">Connect your wallet to list your Walrus blobs.</p>
77
222
  <p v-else-if="error" class="err">{{ error }}</p>
78
- <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>
79
224
 
80
- <table v-if="blobs.length" class="blob-table">
225
+ <table v-if="groups.length" class="blob-table">
81
226
  <thead>
82
227
  <tr>
83
228
  <th>Blob ID</th>
@@ -88,40 +233,101 @@ watch(() => props.address, (addr) => void load(addr))
88
233
  </tr>
89
234
  </thead>
90
235
  <tbody>
91
- <tr
92
- v-for="blob in blobs"
93
- :key="blob.objectId"
94
- :class="{ warn: blob.endEpoch - currentEpoch < EXPIRY_WARN_EPOCHS }"
95
- >
96
- <td>
97
- <CopyableAddress :address="blob.blobId" label="Copy blob ID">
98
- <ExplorerLink
99
- :href="walruscanBlobUrl(NETWORK, blob.blobId)"
100
- :value="blob.blobId"
101
- :chars="[8, 6]"
102
- />
103
- </CopyableAddress>
104
- </td>
105
- <td>{{ (blob.size / 1024).toFixed(1) }} KB</td>
106
- <td>
107
- epoch {{ blob.endEpoch }}
108
- <span class="approx">(≈{{ epochsToApproxDays(blob.endEpoch - currentEpoch) }})</span>
109
- </td>
110
- <td>{{ blob.certified ? '✓' : '—' }}</td>
111
- <td>
112
- <span v-if="extendStatus[blob.objectId]" class="ext-status">
113
- {{ extendStatus[blob.objectId] }}
114
- </span>
115
- <button
116
- v-else
117
- type="button"
118
- :disabled="extending === blob.objectId"
119
- @click="extendBlob(blob)"
120
- >
121
- +10 epochs
122
- </button>
123
- </td>
124
- </tr>
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] }}
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>
319
+ <button
320
+ v-else-if="pendingFor(c.objectId)"
321
+ type="button"
322
+ class="certify-btn"
323
+ :disabled="!!busy"
324
+ @click="certifyBlob(c)"
325
+ >
326
+ Certify
327
+ </button>
328
+ </td>
329
+ </tr>
330
+ </template>
125
331
  </tbody>
126
332
  </table>
127
333
  </section>
@@ -159,6 +365,7 @@ watch(() => props.address, (addr) => void load(addr))
159
365
  text-align: left;
160
366
  padding: 0.4rem 0.6rem;
161
367
  border-bottom: 1px solid var(--mw-color-border, #ddd);
368
+ vertical-align: top;
162
369
  }
163
370
  .blob-table th {
164
371
  font-weight: 600;
@@ -167,15 +374,57 @@ watch(() => props.address, (addr) => void load(addr))
167
374
  .blob-table tr.warn td {
168
375
  background: color-mix(in srgb, var(--mw-color-warning, #f90) 8%, transparent);
169
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
+ }
170
390
  .mono {
171
391
  font-family: monospace;
172
392
  }
173
- .approx {
174
- 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;
411
+ color: var(--mw-color-text-muted, #888);
412
+ }
413
+ .at-max {
414
+ font-size: 0.8rem;
175
415
  color: var(--mw-color-text-muted, #888);
176
416
  }
177
- .ext-status {
417
+ .act-status {
178
418
  font-size: 0.85rem;
179
419
  color: var(--mw-color-text-muted, #888);
180
420
  }
421
+ .certify-btn {
422
+ border-color: var(--accent, #6366f1);
423
+ color: var(--accent, #6366f1);
424
+ }
425
+ .pending-badge {
426
+ font-size: 0.78rem;
427
+ color: var(--accent, #6366f1);
428
+ font-weight: 600;
429
+ }
181
430
  </style>
@@ -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'
@@ -25,6 +24,12 @@ import {
25
24
  isRedeemedConflict,
26
25
  resolveGatedAuthToken,
27
26
  } from '../access-resume.js'
27
+ import {
28
+ pendingCertifyKey,
29
+ savePendingCertify,
30
+ clearPendingCertify,
31
+ loadPendingCertifies,
32
+ } from '../certify-resume.js'
28
33
  import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
29
34
  import MyBlobs from './MyBlobs.vue'
30
35
 
@@ -68,7 +73,12 @@ async function onPurchase(): Promise<void> {
68
73
  // upload-flow.ts). Reusing a prior registration is what produced "the received transaction is too old".
69
74
  async function performUpload(
70
75
  bytes: Uint8Array,
71
- 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
+ },
72
82
  ): Promise<UploadResult> {
73
83
  if (!account.value) throw new Error('Connect your wallet first.')
74
84
  const executor = await buildExecutor()
@@ -110,11 +120,19 @@ async function performUpload(
110
120
  address,
111
121
  wasmUrl: walrusWasmUrl,
112
122
  maxTipMist: uploadRelayMaxTipMist(),
113
- epochs: MAX_SINGLE_RESERVATION_EPOCHS,
123
+ epochs: opts.epochs,
124
+ force: opts.force,
125
+ findExistingCopy,
114
126
  executor,
115
127
  suiClient: getSuiClient(),
116
128
  authToken,
117
129
  onStatus: opts.onStatus,
130
+ // Persist the certificate the moment the upload lands, and drop it once certified — so a
131
+ // dismissed certify can be finished from My Blobs after a tab switch or reload.
132
+ onUploaded: (info) =>
133
+ savePendingCertify(storage, pendingCertifyKey(NETWORK, address), info),
134
+ onCertified: (blobObjectId) =>
135
+ clearPendingCertify(storage, pendingCertifyKey(NETWORK, address), blobObjectId),
118
136
  })
119
137
  // Success → clear the consume layer (the use is now genuinely spent for an upload).
120
138
  if (consumeKey) storage.removeItem(consumeKey)
@@ -140,6 +158,57 @@ async function performUpload(
140
158
  }
141
159
  }
142
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
+
143
212
  const ownedBlobs = useOwnedBlobs()
144
213
 
145
214
  function onUploaded(r: UploadResult): void {
@@ -220,8 +289,10 @@ function onSettled(): void {
220
289
  :connected="!!account"
221
290
  :access="{ gateConfigured: gateState.gateConfigured, hasAccess: gateState.hasAccess }"
222
291
  :perform-upload="performUpload"
292
+ :estimate-storage-cost="estimateUploadStorageCost"
223
293
  @uploaded="onUploaded"
224
294
  @settled="onSettled"
295
+ @manage-existing="onManageExisting"
225
296
  />
226
297
 
227
298
  <section v-if="result" class="result">
@@ -258,6 +329,7 @@ function onSettled(): void {
258
329
  v-if="activeTab === 'blobs'"
259
330
  :address="account?.address ?? null"
260
331
  :build-executor="() => buildExecutor()"
332
+ :highlight-blob-id="highlightBlobId"
261
333
  />
262
334
  </WalletGuard>
263
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,10 +35,17 @@ 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
- upload(opts: { digest: string; deletable?: boolean }): Promise<void>
42
+ // Returns the SDK "uploaded" step, including the on-chain `blobObjectId` and the base64
43
+ // availability `certificate` needed to certify later (persisted so certify can resume).
44
+ upload(opts: { digest: string; deletable?: boolean }): Promise<{
45
+ blobId: string
46
+ blobObjectId: string
47
+ certificate: string
48
+ }>
42
49
  certify(): BuiltTx
43
50
  getBlob(): Promise<{ blobId: string }>
44
51
  }
@@ -55,6 +62,17 @@ export interface RunBlobUploadDeps {
55
62
  maxTipMist: number
56
63
  /** Blob storage reservation length in epochs. */
57
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>
58
76
  executor: UploadExecutor
59
77
  /** A Sui client used to `build()` the register/certify transactions. */
60
78
  suiClient: unknown
@@ -67,6 +85,14 @@ export interface RunBlobUploadDeps {
67
85
  onStatus: (p: UploadProgress) => void
68
86
  /** Lazy loader for the Walrus client module (keeps wasm out of the eager bundle). */
69
87
  loadWalrusClient?: () => Promise<WalrusClientModule>
88
+ /**
89
+ * Called once the upload has landed (blob registered + stored + paid) but BEFORE certify, with the
90
+ * data needed to certify later without re-uploading. Persist it so a dismissed certify can be
91
+ * resumed from My Blobs after a tab switch or reload. Cleared via {@link RunBlobUploadDeps.onCertified}.
92
+ */
93
+ onUploaded?: (info: { blobId: string; blobObjectId: string; certificate: string; deletable: boolean }) => void
94
+ /** Called with the `blobObjectId` once certify succeeds, so the caller can drop the persisted entry. */
95
+ onCertified?: (blobObjectId: string) => void
70
96
  }
71
97
 
72
98
  /**
@@ -93,7 +119,19 @@ export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResu
93
119
  const flow = createBlobUploadFlow(client, deps.bytes)
94
120
 
95
121
  deps.onStatus({ step: 'encode', detail: 'Encoding…' })
96
- 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
+ }
97
135
 
98
136
  deps.onStatus({ step: 'register', detail: 'Registering blob (approve in wallet)…' })
99
137
  const regTx = flow.register({ owner: deps.address, epochs: deps.epochs, deletable: false })
@@ -103,7 +141,16 @@ export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResu
103
141
  await deps.executor.waitForTransaction(reg.digest)
104
142
 
105
143
  deps.onStatus({ step: 'upload', detail: 'Uploading to the relay…' })
106
- await flow.upload({ digest: reg.digest, deletable: false })
144
+ const uploaded = await flow.upload({ digest: reg.digest, deletable: false })
145
+
146
+ // Upload landed (registered + stored + paid). Persist the certificate NOW so certify can be
147
+ // completed later from My Blobs (after a tab switch / reload) if the user dismisses the prompt.
148
+ deps.onUploaded?.({
149
+ blobId: uploaded.blobId,
150
+ blobObjectId: uploaded.blobObjectId,
151
+ certificate: uploaded.certificate,
152
+ deletable: false,
153
+ })
107
154
 
108
155
  // Certify is a plain owner tx built from the storage-node certificate the live `flow` now holds
109
156
  // (no relay, no tip). If it fails (e.g. the user rejects the prompt) the blob is already registered
@@ -117,6 +164,7 @@ export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResu
117
164
  const cert = await deps.executor.signAndExecute(certTx)
118
165
  await deps.executor.waitForTransaction(cert.digest)
119
166
  const blob = await flow.getBlob()
167
+ deps.onCertified?.(uploaded.blobObjectId) // certified → drop the persisted pending entry
120
168
  return { blobId: blob.blobId, url: walrusBlobUrl(deps.network, blob.blobId), digest: cert.digest }
121
169
  }
122
170