@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 +2 -2
- package/src/certify-resume.ts +66 -0
- package/src/components/MyBlobs.vue +107 -1
- package/src/components/WalrusView.vue +11 -0
- package/src/upload-flow.ts +26 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meddleware/walrus-ui",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
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)
|
package/src/upload-flow.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|