@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 +3 -3
- package/src/blob-groups.ts +68 -0
- package/src/certify-resume.ts +66 -0
- package/src/components/MyBlobs.vue +313 -64
- package/src/components/WalrusView.vue +76 -4
- package/src/upload-flow.ts +54 -6
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
|
+
}
|
|
@@ -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
|
|
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
|
-
|
|
23
|
-
const
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
134
|
+
const tx = await extendBlobLifetimeTransaction(walrusClient, blob.objectId, { epochs })
|
|
44
135
|
const executor = await props.buildExecutor()
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
+
busy.value = null
|
|
58
145
|
}
|
|
59
146
|
}
|
|
60
147
|
|
|
61
|
-
//
|
|
62
|
-
|
|
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 &&
|
|
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="
|
|
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
|
-
<
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
.
|
|
174
|
-
|
|
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
|
-
.
|
|
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: {
|
|
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:
|
|
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>
|
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,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
|
-
|
|
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
|
|
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
|
|