@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 +3 -3
- package/src/blob-groups.ts +68 -0
- package/src/components/MyBlobs.vue +267 -124
- package/src/components/WalrusView.vue +65 -4
- package/src/upload-flow.ts +28 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meddleware/walrus-ui",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
48
|
-
"@meddleware/walrus-relay": "^0.1.
|
|
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
|
|
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
|
-
//
|
|
29
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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,
|
|
114
|
+
const { createWalrusClient, estimateStorageCost } = await import('@meddleware/walrus-client')
|
|
64
115
|
const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
+
busy.value = null
|
|
85
145
|
}
|
|
86
146
|
}
|
|
87
147
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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,
|
|
155
|
+
const { createWalrusClient, certifyBlobTransaction } = await import('@meddleware/walrus-client')
|
|
108
156
|
const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
|
|
109
|
-
const tx =
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
+
busy.value = null
|
|
124
174
|
}
|
|
125
175
|
}
|
|
126
176
|
|
|
127
|
-
// Load on first open
|
|
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 &&
|
|
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="
|
|
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
|
-
<
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
pending
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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="
|
|
197
|
-
@click="certifyBlob(
|
|
323
|
+
:disabled="!!busy"
|
|
324
|
+
@click="certifyBlob(c)"
|
|
198
325
|
>
|
|
199
326
|
Certify
|
|
200
327
|
</button>
|
|
201
|
-
</
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
.
|
|
265
|
-
|
|
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
|
-
.
|
|
269
|
-
font-size: 0.
|
|
413
|
+
.at-max {
|
|
414
|
+
font-size: 0.8rem;
|
|
270
415
|
color: var(--mw-color-text-muted, #888);
|
|
271
416
|
}
|
|
272
|
-
.
|
|
273
|
-
|
|
274
|
-
|
|
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: {
|
|
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:
|
|
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>
|
package/src/upload-flow.ts
CHANGED
|
@@ -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
|
-
|
|
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 })
|