@meddleware/walrus-ui 0.1.20 → 0.1.23
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/access-resume.ts +5 -48
- package/src/components/WalrusView.vue +34 -56
- package/src/upload-flow.ts +22 -40
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meddleware/walrus-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
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.9",
|
|
48
48
|
"@meddleware/walrus-relay": "^0.1.8",
|
|
49
49
|
"@mysten/sui": "^2.30.0",
|
|
50
50
|
"@mysten/wallet-standard": "^0.20.0",
|
package/src/access-resume.ts
CHANGED
|
@@ -21,54 +21,11 @@ export function consumeStorageKey(network: string, gateId: string, address: stri
|
|
|
21
21
|
return `mw:walrus:consume:${network}:${gateId}:${address}`
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
/** Stable per-(network, address) key under which a pending register digest + content hash is stored. */
|
|
31
|
-
export function registerStorageKey(network: string, address: string): string {
|
|
32
|
-
return `mw:walrus:register:${network}:${address}`
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Cheap content fingerprint (length + head/tail bytes) to match a retry to the same file. */
|
|
36
|
-
export function contentKey(bytes: Uint8Array): string {
|
|
37
|
-
const head = Array.from(bytes.slice(0, 16)).join(',')
|
|
38
|
-
const tail = Array.from(bytes.slice(-16)).join(',')
|
|
39
|
-
return `${bytes.length}:${head}:${tail}`
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Return the stored register digest iff it was saved for this exact file content. */
|
|
43
|
-
export function loadRegisterResume(
|
|
44
|
-
storage: StorageLike,
|
|
45
|
-
key: string,
|
|
46
|
-
content: string,
|
|
47
|
-
): string | null {
|
|
48
|
-
const raw = storage.getItem(key)
|
|
49
|
-
if (!raw) return null
|
|
50
|
-
try {
|
|
51
|
-
const v = JSON.parse(raw) as { contentKey?: string; registerDigest?: string }
|
|
52
|
-
return v.contentKey === content && v.registerDigest ? v.registerDigest : null
|
|
53
|
-
} catch {
|
|
54
|
-
return null
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Persist a register digest against a file content key so the upload can resume later. */
|
|
59
|
-
export function saveRegisterResume(
|
|
60
|
-
storage: StorageLike,
|
|
61
|
-
key: string,
|
|
62
|
-
content: string,
|
|
63
|
-
registerDigest: string,
|
|
64
|
-
): void {
|
|
65
|
-
storage.setItem(key, JSON.stringify({ contentKey: content, registerDigest }))
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Clear any stored register-resume entry (on success, or to fall back to a fresh register). */
|
|
69
|
-
export function clearRegisterResume(storage: StorageLike, key: string): void {
|
|
70
|
-
storage.removeItem(key)
|
|
71
|
-
}
|
|
24
|
+
// NOTE: there is deliberately no register-resume layer. With an upload relay (required for browser
|
|
25
|
+
// uploads) the SDK embeds the relay tip + a per-encode nonce INSIDE the register transaction, and
|
|
26
|
+
// the relay rejects a stale `tx_id` as "the received transaction is too old". A registration thus
|
|
27
|
+
// can't be reused across attempts, so every upload registers fresh (see upload-flow.ts). Only the
|
|
28
|
+
// single-use consume digest below is resumable — it's a permanent on-chain token, not a tx that ages.
|
|
72
29
|
|
|
73
30
|
/**
|
|
74
31
|
* True if `err` is the gateway's "this consume was already redeemed" rejection (HTTP 409 with
|
|
@@ -23,11 +23,6 @@ import {
|
|
|
23
23
|
consumeStorageKey,
|
|
24
24
|
isRedeemedConflict,
|
|
25
25
|
resolveGatedAuthToken,
|
|
26
|
-
registerStorageKey,
|
|
27
|
-
contentKey,
|
|
28
|
-
loadRegisterResume,
|
|
29
|
-
saveRegisterResume,
|
|
30
|
-
clearRegisterResume,
|
|
31
26
|
} from '../access-resume.js'
|
|
32
27
|
import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
|
|
33
28
|
import MyBlobs from './MyBlobs.vue'
|
|
@@ -63,15 +58,13 @@ async function onPurchase(): Promise<void> {
|
|
|
63
58
|
|
|
64
59
|
// Wire the shared WalrusUpload widget to the extracted upload orchestration + the wallet. The
|
|
65
60
|
// register/upload/certify sequence lives in src/upload-flow.ts (unit-tested); this closure gathers
|
|
66
|
-
// the wallet-bound inputs and manages
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
// (no new WAL/gas). Cleared on success, or when a resumed attempt itself fails (fall back to a
|
|
74
|
-
// fresh register).
|
|
61
|
+
// the wallet-bound inputs and manages the single-use consume resume layer:
|
|
62
|
+
// Single-use consume: the relay treats the permanent on-chain `consumeDigest` as the one-time
|
|
63
|
+
// redemption token, so a use is only spent when an upload succeeds. The digest is persisted and
|
|
64
|
+
// reused across retries/reload (re-signing a fresh challenge is free); cleared on success.
|
|
65
|
+
// The Walrus registration is NOT resumed — with an upload relay the tip + nonce live in the register
|
|
66
|
+
// transaction and the relay requires it to be recent, so every attempt registers fresh (see
|
|
67
|
+
// upload-flow.ts). Reusing a prior registration is what produced "the received transaction is too old".
|
|
75
68
|
async function performUpload(
|
|
76
69
|
bytes: Uint8Array,
|
|
77
70
|
opts: { relayHost: string; onStatus: (s: string) => void },
|
|
@@ -79,12 +72,10 @@ async function performUpload(
|
|
|
79
72
|
if (!account.value) throw new Error('Connect your wallet first.')
|
|
80
73
|
const executor = await buildExecutor()
|
|
81
74
|
const address = account.value.address
|
|
82
|
-
const key = contentKey(bytes)
|
|
83
75
|
const storage = window.localStorage
|
|
84
76
|
|
|
85
77
|
const gated = !!(gate && gateState.hasAccess.value === true && gateState.nftId.value)
|
|
86
78
|
const consumeKey = gate ? consumeStorageKey(NETWORK, gate.gateId, address) : null
|
|
87
|
-
const regKey = registerStorageKey(NETWORK, address)
|
|
88
79
|
|
|
89
80
|
// Resolve this attempt's relay token (gated only); reuses a stored consume, fresh challenge each time.
|
|
90
81
|
const token = (forceFresh: boolean): Promise<string | undefined> =>
|
|
@@ -106,35 +97,22 @@ async function performUpload(
|
|
|
106
97
|
})
|
|
107
98
|
|
|
108
99
|
const runOnce = async (authToken: string | undefined): Promise<UploadResult> => {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
onRegistered: (digest) => saveRegisterResume(storage, regKey, key, digest),
|
|
126
|
-
})
|
|
127
|
-
// Success → clear both resume layers (the use is now genuinely spent for an upload).
|
|
128
|
-
clearRegisterResume(storage, regKey)
|
|
129
|
-
if (consumeKey) storage.removeItem(consumeKey)
|
|
130
|
-
return r
|
|
131
|
-
} catch (e) {
|
|
132
|
-
// A resumed attempt that fails drops the saved register digest so the next try does a full
|
|
133
|
-
// fresh register (never worse than today). A non-resumed failure keeps the digest that
|
|
134
|
-
// onRegistered saved, so the next try (or a reload + re-select) resumes without re-registering.
|
|
135
|
-
if (resumeRegisterDigest !== undefined) clearRegisterResume(storage, regKey)
|
|
136
|
-
throw e
|
|
137
|
-
}
|
|
100
|
+
const r = await runBlobUpload({
|
|
101
|
+
bytes,
|
|
102
|
+
network: NETWORK,
|
|
103
|
+
relayHost: opts.relayHost,
|
|
104
|
+
address,
|
|
105
|
+
wasmUrl: walrusWasmUrl,
|
|
106
|
+
maxTipMist: uploadRelayMaxTipMist(),
|
|
107
|
+
epochs: MAX_SINGLE_RESERVATION_EPOCHS,
|
|
108
|
+
executor,
|
|
109
|
+
suiClient: getSuiClient(),
|
|
110
|
+
authToken,
|
|
111
|
+
onStatus: opts.onStatus,
|
|
112
|
+
})
|
|
113
|
+
// Success → clear the consume layer (the use is now genuinely spent for an upload).
|
|
114
|
+
if (consumeKey) storage.removeItem(consumeKey)
|
|
115
|
+
return r
|
|
138
116
|
}
|
|
139
117
|
|
|
140
118
|
try {
|
|
@@ -142,11 +120,11 @@ async function performUpload(
|
|
|
142
120
|
} catch (e) {
|
|
143
121
|
if (gated && isRedeemedConflict(e)) {
|
|
144
122
|
// Stored consume already redeemed (a prior upload actually landed): clear it, spend a fresh
|
|
145
|
-
// use, and retry
|
|
123
|
+
// use, and retry.
|
|
146
124
|
storage.removeItem(consumeKey as string)
|
|
147
125
|
return await runOnce(await token(true))
|
|
148
126
|
}
|
|
149
|
-
throw e
|
|
127
|
+
throw e
|
|
150
128
|
}
|
|
151
129
|
}
|
|
152
130
|
|
|
@@ -214,15 +192,15 @@ function onSettled(): void {
|
|
|
214
192
|
|
|
215
193
|
<!-- Access held (or ungated relay): show the upload form. -->
|
|
216
194
|
<template v-else>
|
|
217
|
-
<!-- Gated relays spend a
|
|
195
|
+
<!-- Gated relays spend a credit before the file is stored — make the "attempt, not a
|
|
218
196
|
guarantee" nature explicit, while reassuring that attempts resume. -->
|
|
219
|
-
<UiNotice v-if="gateState.gateConfigured" type="info" class="
|
|
220
|
-
Uploading spends <strong>one
|
|
221
|
-
the file is stored — it pays for an upload <em>attempt</em>, not a guaranteed
|
|
222
|
-
Your attempt resumes automatically, even after a page reload if you re-select
|
|
223
|
-
file, so a
|
|
224
|
-
you abandon the upload entirely, cancel a required wallet approval, or
|
|
225
|
-
that the reserved storage lapses.
|
|
197
|
+
<UiNotice v-if="gateState.gateConfigured" type="info" class="credit-notice">
|
|
198
|
+
Uploading spends <strong>one credit</strong> from your access NFT (an on-chain step)
|
|
199
|
+
before the file is stored — it pays for an upload <em>attempt</em>, not a guaranteed
|
|
200
|
+
upload. Your attempt resumes automatically, even after a page reload if you re-select
|
|
201
|
+
the same file, so a credit is normally not lost. A credit is spent without a completed
|
|
202
|
+
upload only if you abandon the upload entirely, cancel a required wallet approval, or
|
|
203
|
+
wait long enough that the reserved storage lapses.
|
|
226
204
|
</UiNotice>
|
|
227
205
|
|
|
228
206
|
<WalrusUpload
|
|
@@ -313,7 +291,7 @@ function onSettled(): void {
|
|
|
313
291
|
text-align: center;
|
|
314
292
|
}
|
|
315
293
|
|
|
316
|
-
.
|
|
294
|
+
.credit-notice {
|
|
317
295
|
margin: 1rem 0;
|
|
318
296
|
font-size: 0.85rem;
|
|
319
297
|
line-height: 1.5;
|
package/src/upload-flow.ts
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
// The `@mysten/walrus` wasm client must NOT be pulled into the eager module graph (see CLAUDE.md),
|
|
5
5
|
// so this module never imports @meddleware/walrus-client at top level — it loads it lazily through
|
|
6
6
|
// the injectable `loadWalrusClient` (default: a dynamic import). Tests inject a fake loader.
|
|
7
|
+
//
|
|
8
|
+
// Register is ALWAYS performed, never resumed/skipped. With an upload relay (required for browser
|
|
9
|
+
// uploads) the SDK embeds the relay tip + a per-encode `nonce` INSIDE the register transaction, and
|
|
10
|
+
// the relay rejects a stale `tx_id` as "the received transaction is too old". A register tx therefore
|
|
11
|
+
// can't be reused across attempts — reusing a prior/discovered registration (localStorage or on-chain
|
|
12
|
+
// discovery) hands the relay an old tx with a non-matching nonce. Each attempt re-encodes (free) and
|
|
13
|
+
// registers fresh so the tip+nonce the relay verifies is always recent.
|
|
7
14
|
import type { UploadResult } from '@meddleware/walrus-relay'
|
|
8
15
|
|
|
9
16
|
/** Minimal transaction executor — the structural subset App.vue's wallet executor already provides. */
|
|
@@ -25,11 +32,11 @@ interface BuiltTx {
|
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
export interface BlobUploadFlow {
|
|
35
|
+
// Encoding is deterministic from the content and costs no gas; it also mints the per-attempt relay
|
|
36
|
+
// `nonce` committed by the register tip, so it must precede register/upload on this flow instance.
|
|
28
37
|
encode(): Promise<void>
|
|
29
38
|
register(opts: { owner: string; epochs: number; deletable: boolean }): BuiltTx
|
|
30
|
-
// `digest` = the register transaction digest
|
|
31
|
-
// this flow instance, the SDK accepts the digest + `deletable` to upload against the already
|
|
32
|
-
// registered blob (see `@mysten/walrus` WriteBlobFlowUploadOptions).
|
|
39
|
+
// `digest` = the register transaction digest produced by the register tx executed this attempt.
|
|
33
40
|
upload(opts: { digest: string; deletable?: boolean }): Promise<void>
|
|
34
41
|
certify(): BuiltTx
|
|
35
42
|
getBlob(): Promise<{ blobId: string }>
|
|
@@ -51,33 +58,20 @@ export interface RunBlobUploadDeps {
|
|
|
51
58
|
/** A Sui client used to `build()` the register/certify transactions. */
|
|
52
59
|
suiClient: unknown
|
|
53
60
|
/**
|
|
54
|
-
* Bearer proof token for an NFT-gated relay. May be a provider resolved per request so a
|
|
61
|
+
* Bearer proof token for an NFT-gated relay. May be a provider resolved per request so a retried
|
|
55
62
|
* upload presents a fresh challenge signature (see `@meddleware/walrus-client`).
|
|
56
63
|
*/
|
|
57
64
|
authToken?: string | (() => string | undefined)
|
|
58
65
|
onStatus: (s: string) => void
|
|
59
66
|
/** Lazy loader for the Walrus client module (keeps wasm out of the eager bundle). */
|
|
60
67
|
loadWalrusClient?: () => Promise<WalrusClientModule>
|
|
61
|
-
/**
|
|
62
|
-
* Resume from a blob registered in a PRIOR attempt (this session or a previous page load) by
|
|
63
|
-
* passing its register transaction digest. The file is re-encoded (client-side, no gas) but the
|
|
64
|
-
* on-chain `register` transaction is skipped — so an interrupted upload never re-registers (no new
|
|
65
|
-
* WAL/gas), even across a reload once the same file is re-selected. Omit for a fresh upload.
|
|
66
|
-
*/
|
|
67
|
-
resumeRegisterDigest?: string
|
|
68
|
-
/**
|
|
69
|
-
* Called with the register transaction digest immediately after a fresh register succeeds, so the
|
|
70
|
-
* caller can PERSIST it (e.g. to localStorage) and resume the upload after a failure/reload
|
|
71
|
-
* without re-registering.
|
|
72
|
-
*/
|
|
73
|
-
onRegistered?: (registerDigest: string) => void
|
|
74
68
|
}
|
|
75
69
|
|
|
76
70
|
/**
|
|
77
|
-
* Register → upload → certify a blob and resolve its id + public URL.
|
|
78
|
-
* (client-side, no gas)
|
|
79
|
-
*
|
|
80
|
-
* (register
|
|
71
|
+
* Register → upload → certify a blob and resolve its id + public URL. Every attempt encodes
|
|
72
|
+
* (client-side, no gas) and registers fresh: the relay tip + nonce the relay verifies live in the
|
|
73
|
+
* register transaction and must be recent, so a registration is never reused across attempts. Two
|
|
74
|
+
* wallet approvals (register and certify) are requested via `executor`; `onStatus` narrates.
|
|
81
75
|
*/
|
|
82
76
|
export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResult> {
|
|
83
77
|
// The real module's flow types are richer than the narrow structural subset we use here, so the
|
|
@@ -96,30 +90,18 @@ export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResu
|
|
|
96
90
|
})
|
|
97
91
|
const flow = createBlobUploadFlow(client, deps.bytes)
|
|
98
92
|
|
|
99
|
-
// Encoding is deterministic from the content and costs no gas, so it always runs — including on a
|
|
100
|
-
// resume, where it re-derives the slivers for the re-selected file.
|
|
101
93
|
deps.onStatus('Encoding…')
|
|
102
94
|
await flow.encode()
|
|
103
95
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
deps.onStatus('Registering blob (approve in wallet)…')
|
|
111
|
-
const regTx = flow.register({ owner: deps.address, epochs: deps.epochs, deletable: false })
|
|
112
|
-
regTx.setSenderIfNotSet(deps.address)
|
|
113
|
-
await regTx.build({ client: deps.suiClient })
|
|
114
|
-
const reg = await deps.executor.signAndExecute(regTx)
|
|
115
|
-
await deps.executor.waitForTransaction(reg.digest)
|
|
116
|
-
registerDigest = reg.digest
|
|
117
|
-
// Persist point: hand back the digest so the upload can be resumed after a failure/reload.
|
|
118
|
-
deps.onRegistered?.(registerDigest)
|
|
119
|
-
}
|
|
96
|
+
deps.onStatus('Registering blob (approve in wallet)…')
|
|
97
|
+
const regTx = flow.register({ owner: deps.address, epochs: deps.epochs, deletable: false })
|
|
98
|
+
regTx.setSenderIfNotSet(deps.address)
|
|
99
|
+
await regTx.build({ client: deps.suiClient })
|
|
100
|
+
const reg = await deps.executor.signAndExecute(regTx)
|
|
101
|
+
await deps.executor.waitForTransaction(reg.digest)
|
|
120
102
|
|
|
121
103
|
deps.onStatus('Uploading to the relay…')
|
|
122
|
-
await flow.upload({ digest:
|
|
104
|
+
await flow.upload({ digest: reg.digest, deletable: false })
|
|
123
105
|
|
|
124
106
|
deps.onStatus('Certifying (approve in wallet)…')
|
|
125
107
|
const certTx = flow.certify()
|