@meddleware/walrus-ui 0.1.25 → 0.1.26

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.26",
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,7 +44,7 @@
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",
47
+ "@meddleware/walrus-client": "^0.0.10",
48
48
  "@meddleware/walrus-relay": "^0.1.10",
49
49
  "@mysten/sui": "^2.30.0",
50
50
  "@mysten/wallet-standard": "^0.20.0",
@@ -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
+ }
@@ -6,6 +6,12 @@ import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
6
6
  import type { Executor } from '../wallet.js'
7
7
  import { NETWORK, walruscanBlobUrl } from '../config.js'
8
8
  import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
9
+ import {
10
+ pendingCertifyKey,
11
+ loadPendingCertifies,
12
+ clearPendingCertify,
13
+ type PendingCertify,
14
+ } from '../certify-resume.js'
9
15
 
10
16
  const props = defineProps<{
11
17
  /** Connected wallet address whose owned blobs to list; `null` when no wallet is connected. */
@@ -19,6 +25,66 @@ const { blobs, currentEpoch, loading, error, load } = useOwnedBlobs()
19
25
  const extending = ref<string | null>(null)
20
26
  const extendStatus = ref<Record<string, string>>({})
21
27
 
28
+ // Pending certifications: blobs uploaded + paid for but not yet certified (the certify prompt was
29
+ // dismissed). Persisted by the upload flow, keyed by blobObjectId; resumable here without re-upload.
30
+ const pending = ref<Record<string, PendingCertify>>({})
31
+ const certifying = ref<string | null>(null)
32
+ const certifyStatus = ref<Record<string, string>>({})
33
+
34
+ /** The pending-certify entry for a blob, if it's uncertified and we hold its certificate. */
35
+ function pendingFor(blob: OwnedBlob): PendingCertify | null {
36
+ return !blob.certified ? (pending.value[blob.objectId] ?? null) : null
37
+ }
38
+
39
+ /** Reload the pending map from storage, dropping entries whose blob is already certified. */
40
+ function refreshPending(): void {
41
+ if (!props.address) {
42
+ pending.value = {}
43
+ return
44
+ }
45
+ const key = pendingCertifyKey(NETWORK, props.address)
46
+ const map = loadPendingCertifies(window.localStorage, key)
47
+ // Housekeeping: a blob certified elsewhere no longer needs a stored certificate.
48
+ for (const blob of blobs.value) {
49
+ if (blob.certified && blob.objectId in map) {
50
+ clearPendingCertify(window.localStorage, key, blob.objectId)
51
+ delete map[blob.objectId]
52
+ }
53
+ }
54
+ pending.value = map
55
+ }
56
+
57
+ async function certifyBlob(blob: OwnedBlob): Promise<void> {
58
+ const entry = pendingFor(blob)
59
+ if (!entry || !props.address) return
60
+ certifying.value = blob.objectId
61
+ certifyStatus.value = { ...certifyStatus.value, [blob.objectId]: 'Building transaction…' }
62
+ try {
63
+ const { createWalrusClient, certifyBlobTransaction } = await import('@meddleware/walrus-client')
64
+ const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
65
+ const tx = certifyBlobTransaction(walrusClient, {
66
+ blobId: entry.blobId,
67
+ blobObjectId: entry.blobObjectId,
68
+ certificate: entry.certificate,
69
+ deletable: entry.deletable,
70
+ })
71
+ const executor = await props.buildExecutor()
72
+ certifyStatus.value = { ...certifyStatus.value, [blob.objectId]: 'Approve in wallet…' }
73
+ const { digest } = await executor.signAndExecute(tx)
74
+ await executor.waitForTransaction(digest)
75
+ clearPendingCertify(window.localStorage, pendingCertifyKey(NETWORK, props.address), blob.objectId)
76
+ certifyStatus.value = { ...certifyStatus.value, [blob.objectId]: `Certified ✓ (${digest.slice(0, 8)}…)` }
77
+ await refresh() // reflect certified = ✓
78
+ } catch (e) {
79
+ certifyStatus.value = {
80
+ ...certifyStatus.value,
81
+ [blob.objectId]: `Failed: ${e instanceof Error ? e.message : String(e)}`,
82
+ }
83
+ } finally {
84
+ certifying.value = null
85
+ }
86
+ }
87
+
22
88
  const EXPIRY_WARN_EPOCHS = 10
23
89
  const EPOCHS_PER_DAY = 1 / 0.038 // ~1 Walrus epoch ≈ 38 minutes on testnet
24
90
 
@@ -62,6 +128,9 @@ async function extendBlob(blob: OwnedBlob): Promise<void> {
62
128
  // The composable no-ops when the list is already cached for this address, so re-mounting is cheap.
63
129
  onMounted(() => void load(props.address))
64
130
  watch(() => props.address, (addr) => void load(addr))
131
+ // Re-derive pending certifications whenever the list refreshes or the account changes.
132
+ watch(blobs, () => refreshPending())
133
+ watch(() => props.address, () => refreshPending())
65
134
  </script>
66
135
 
67
136
  <template>
@@ -107,8 +176,30 @@ watch(() => props.address, (addr) => void load(addr))
107
176
  epoch {{ blob.endEpoch }}
108
177
  <span class="approx">(≈{{ epochsToApproxDays(blob.endEpoch - currentEpoch) }})</span>
109
178
  </td>
110
- <td>{{ blob.certified ? '✓' : '—' }}</td>
111
179
  <td>
180
+ <span v-if="blob.certified">✓</span>
181
+ <span v-else-if="pendingFor(blob)" class="pending-badge" title="Uploaded but not certified">
182
+ pending
183
+ </span>
184
+ <span v-else>—</span>
185
+ </td>
186
+ <td class="actions">
187
+ <!-- Certify: the blob was uploaded + paid for but not certified; finish it (no re-upload). -->
188
+ <template v-if="pendingFor(blob)">
189
+ <span v-if="certifyStatus[blob.objectId]" class="ext-status">
190
+ {{ certifyStatus[blob.objectId] }}
191
+ </span>
192
+ <button
193
+ v-else
194
+ type="button"
195
+ class="certify-btn"
196
+ :disabled="certifying === blob.objectId"
197
+ @click="certifyBlob(blob)"
198
+ >
199
+ Certify
200
+ </button>
201
+ </template>
202
+
112
203
  <span v-if="extendStatus[blob.objectId]" class="ext-status">
113
204
  {{ extendStatus[blob.objectId] }}
114
205
  </span>
@@ -178,4 +269,19 @@ watch(() => props.address, (addr) => void load(addr))
178
269
  font-size: 0.85rem;
179
270
  color: var(--mw-color-text-muted, #888);
180
271
  }
272
+ .actions {
273
+ display: flex;
274
+ flex-wrap: wrap;
275
+ gap: 0.4rem;
276
+ align-items: center;
277
+ }
278
+ .certify-btn {
279
+ border-color: var(--accent, #6366f1);
280
+ color: var(--accent, #6366f1);
281
+ }
282
+ .pending-badge {
283
+ font-size: 0.78rem;
284
+ color: var(--accent, #6366f1);
285
+ font-weight: 600;
286
+ }
181
287
  </style>
@@ -25,6 +25,11 @@ import {
25
25
  isRedeemedConflict,
26
26
  resolveGatedAuthToken,
27
27
  } from '../access-resume.js'
28
+ import {
29
+ pendingCertifyKey,
30
+ savePendingCertify,
31
+ clearPendingCertify,
32
+ } from '../certify-resume.js'
28
33
  import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
29
34
  import MyBlobs from './MyBlobs.vue'
30
35
 
@@ -115,6 +120,12 @@ async function performUpload(
115
120
  suiClient: getSuiClient(),
116
121
  authToken,
117
122
  onStatus: opts.onStatus,
123
+ // Persist the certificate the moment the upload lands, and drop it once certified — so a
124
+ // dismissed certify can be finished from My Blobs after a tab switch or reload.
125
+ onUploaded: (info) =>
126
+ savePendingCertify(storage, pendingCertifyKey(NETWORK, address), info),
127
+ onCertified: (blobObjectId) =>
128
+ clearPendingCertify(storage, pendingCertifyKey(NETWORK, address), blobObjectId),
118
129
  })
119
130
  // Success → clear the consume layer (the use is now genuinely spent for an upload).
120
131
  if (consumeKey) storage.removeItem(consumeKey)
@@ -38,7 +38,13 @@ export interface BlobUploadFlow {
38
38
  encode(): Promise<void>
39
39
  register(opts: { owner: string; epochs: number; deletable: boolean }): BuiltTx
40
40
  // `digest` = the register transaction digest produced by the register tx executed this attempt.
41
- upload(opts: { digest: string; deletable?: boolean }): Promise<void>
41
+ // Returns the SDK "uploaded" step, including the on-chain `blobObjectId` and the base64
42
+ // availability `certificate` needed to certify later (persisted so certify can resume).
43
+ upload(opts: { digest: string; deletable?: boolean }): Promise<{
44
+ blobId: string
45
+ blobObjectId: string
46
+ certificate: string
47
+ }>
42
48
  certify(): BuiltTx
43
49
  getBlob(): Promise<{ blobId: string }>
44
50
  }
@@ -67,6 +73,14 @@ export interface RunBlobUploadDeps {
67
73
  onStatus: (p: UploadProgress) => void
68
74
  /** Lazy loader for the Walrus client module (keeps wasm out of the eager bundle). */
69
75
  loadWalrusClient?: () => Promise<WalrusClientModule>
76
+ /**
77
+ * Called once the upload has landed (blob registered + stored + paid) but BEFORE certify, with the
78
+ * data needed to certify later without re-uploading. Persist it so a dismissed certify can be
79
+ * resumed from My Blobs after a tab switch or reload. Cleared via {@link RunBlobUploadDeps.onCertified}.
80
+ */
81
+ onUploaded?: (info: { blobId: string; blobObjectId: string; certificate: string; deletable: boolean }) => void
82
+ /** Called with the `blobObjectId` once certify succeeds, so the caller can drop the persisted entry. */
83
+ onCertified?: (blobObjectId: string) => void
70
84
  }
71
85
 
72
86
  /**
@@ -103,7 +117,16 @@ export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResu
103
117
  await deps.executor.waitForTransaction(reg.digest)
104
118
 
105
119
  deps.onStatus({ step: 'upload', detail: 'Uploading to the relay…' })
106
- await flow.upload({ digest: reg.digest, deletable: false })
120
+ const uploaded = await flow.upload({ digest: reg.digest, deletable: false })
121
+
122
+ // Upload landed (registered + stored + paid). Persist the certificate NOW so certify can be
123
+ // completed later from My Blobs (after a tab switch / reload) if the user dismisses the prompt.
124
+ deps.onUploaded?.({
125
+ blobId: uploaded.blobId,
126
+ blobObjectId: uploaded.blobObjectId,
127
+ certificate: uploaded.certificate,
128
+ deletable: false,
129
+ })
107
130
 
108
131
  // Certify is a plain owner tx built from the storage-node certificate the live `flow` now holds
109
132
  // (no relay, no tip). If it fails (e.g. the user rejects the prompt) the blob is already registered
@@ -117,6 +140,7 @@ export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResu
117
140
  const cert = await deps.executor.signAndExecute(certTx)
118
141
  await deps.executor.waitForTransaction(cert.digest)
119
142
  const blob = await flow.getBlob()
143
+ deps.onCertified?.(uploaded.blobObjectId) // certified → drop the persisted pending entry
120
144
  return { blobId: blob.blobId, url: walrusBlobUrl(deps.network, blob.blobId), digest: cert.digest }
121
145
  }
122
146