@meddleware/walrus-ui 0.1.24 → 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.24",
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,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.9",
47
+ "@meddleware/walrus-client": "^0.0.10",
48
+ "@meddleware/walrus-relay": "^0.1.10",
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,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>
@@ -19,11 +19,17 @@ import { useWallet, getSuiClient } from '../wallet.js'
19
19
  import { fetchChallenge, buildAccessProof } from '@meddleware/nft-gate-client'
20
20
  import { NETWORK, relayHosts, accessGate, uploadRelayMaxTipMist, walruscanBlobUrl } from '../config.js'
21
21
  import { runBlobUpload } from '../upload-flow.js'
22
+ import { getCertifyRetry } from '@meddleware/walrus-relay'
22
23
  import {
23
24
  consumeStorageKey,
24
25
  isRedeemedConflict,
25
26
  resolveGatedAuthToken,
26
27
  } from '../access-resume.js'
28
+ import {
29
+ pendingCertifyKey,
30
+ savePendingCertify,
31
+ clearPendingCertify,
32
+ } from '../certify-resume.js'
27
33
  import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
28
34
  import MyBlobs from './MyBlobs.vue'
29
35
 
@@ -101,22 +107,35 @@ async function performUpload(
101
107
  })
102
108
 
103
109
  const runOnce = async (authToken: string | undefined): Promise<UploadResult> => {
104
- const r = await runBlobUpload({
105
- bytes,
106
- network: NETWORK,
107
- relayHost: opts.relayHost,
108
- address,
109
- wasmUrl: walrusWasmUrl,
110
- maxTipMist: uploadRelayMaxTipMist(),
111
- epochs: MAX_SINGLE_RESERVATION_EPOCHS,
112
- executor,
113
- suiClient: getSuiClient(),
114
- authToken,
115
- onStatus: opts.onStatus,
116
- })
117
- // Success clear the consume layer (the use is now genuinely spent for an upload).
118
- if (consumeKey) storage.removeItem(consumeKey)
119
- return r
110
+ try {
111
+ const r = await runBlobUpload({
112
+ bytes,
113
+ network: NETWORK,
114
+ relayHost: opts.relayHost,
115
+ address,
116
+ wasmUrl: walrusWasmUrl,
117
+ maxTipMist: uploadRelayMaxTipMist(),
118
+ epochs: MAX_SINGLE_RESERVATION_EPOCHS,
119
+ executor,
120
+ suiClient: getSuiClient(),
121
+ authToken,
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),
129
+ })
130
+ // Success → clear the consume layer (the use is now genuinely spent for an upload).
131
+ if (consumeKey) storage.removeItem(consumeKey)
132
+ return r
133
+ } catch (e) {
134
+ // A certify-only failure means the upload already landed (relay access was used), so clear the
135
+ // consume too — the certify retry is a plain Sui tx and must not trigger a fresh NFT consume.
136
+ if (getCertifyRetry(e) && consumeKey) storage.removeItem(consumeKey)
137
+ throw e
138
+ }
120
139
  }
121
140
 
122
141
  try {
@@ -12,6 +12,7 @@
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
14
  import type { UploadResult, UploadProgress } from '@meddleware/walrus-relay'
15
+ import { attachCertifyRetry } from '@meddleware/walrus-relay'
15
16
 
16
17
  /** Minimal transaction executor — the structural subset App.vue's wallet executor already provides. */
17
18
  export interface UploadExecutor {
@@ -37,7 +38,13 @@ export interface BlobUploadFlow {
37
38
  encode(): Promise<void>
38
39
  register(opts: { owner: string; epochs: number; deletable: boolean }): BuiltTx
39
40
  // `digest` = the register transaction digest produced by the register tx executed this attempt.
40
- 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
+ }>
41
48
  certify(): BuiltTx
42
49
  getBlob(): Promise<{ blobId: string }>
43
50
  }
@@ -66,6 +73,14 @@ export interface RunBlobUploadDeps {
66
73
  onStatus: (p: UploadProgress) => void
67
74
  /** Lazy loader for the Walrus client module (keeps wasm out of the eager bundle). */
68
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
69
84
  }
70
85
 
71
86
  /**
@@ -102,15 +117,39 @@ export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResu
102
117
  await deps.executor.waitForTransaction(reg.digest)
103
118
 
104
119
  deps.onStatus({ step: 'upload', detail: 'Uploading to the relay…' })
105
- 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
+ })
106
130
 
107
- deps.onStatus({ step: 'certify', detail: 'Certifying (approve in wallet)…' })
108
- const certTx = flow.certify()
109
- certTx.setSenderIfNotSet(deps.address)
110
- await certTx.build({ client: deps.suiClient })
111
- const cert = await deps.executor.signAndExecute(certTx)
112
- await deps.executor.waitForTransaction(cert.digest)
131
+ // Certify is a plain owner tx built from the storage-node certificate the live `flow` now holds
132
+ // (no relay, no tip). If it fails (e.g. the user rejects the prompt) the blob is already registered
133
+ // + stored + paid, so we never want to redo the upload — we expose this closure to retry certify
134
+ // alone. Re-calling `flow.certify()` just rebuilds the same tx from the in-memory certificate.
135
+ const runCertify = async (): Promise<UploadResult> => {
136
+ deps.onStatus({ step: 'certify', detail: 'Certifying (approve in wallet)…' })
137
+ const certTx = flow.certify()
138
+ certTx.setSenderIfNotSet(deps.address)
139
+ await certTx.build({ client: deps.suiClient })
140
+ const cert = await deps.executor.signAndExecute(certTx)
141
+ await deps.executor.waitForTransaction(cert.digest)
142
+ const blob = await flow.getBlob()
143
+ deps.onCertified?.(uploaded.blobObjectId) // certified → drop the persisted pending entry
144
+ return { blobId: blob.blobId, url: walrusBlobUrl(deps.network, blob.blobId), digest: cert.digest }
145
+ }
113
146
 
114
- const blob = await flow.getBlob()
115
- return { blobId: blob.blobId, url: walrusBlobUrl(deps.network, blob.blobId), digest: cert.digest }
147
+ try {
148
+ return await runCertify()
149
+ } catch (e) {
150
+ // Upload landed but certify did not: attach a retry so the UI can offer a "Certify" button
151
+ // rather than discarding the paid upload. The closure keeps the live flow (and its certificate).
152
+ attachCertifyRetry<UploadResult>(e, runCertify)
153
+ throw e
154
+ }
116
155
  }