@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meddleware/walrus-ui",
3
- "version": "0.1.20",
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.8",
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",
@@ -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
- // ── Register-resume (Walrus flow) ───────────────────────────────────────────────────────────
25
- // A blob registered on-chain but not yet uploaded/certified can be resumed WITHOUT re-registering
26
- // (no new WAL/gas): persist its register transaction digest, keyed by a hash of the file content,
27
- // and reuse it when the same file is uploaded again even after a page reload, since the user
28
- // re-selects the file (only the tiny digest is persisted, never the bytes).
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 two resume layers so an interrupted upload wastes nothing —
67
- // both persisted in localStorage, so they survive a page reload once the same file is re-selected:
68
- // 1. Single-use consume: the relay treats the permanent on-chain `consumeDigest` as the one-time
69
- // redemption token, so a use is only spent when an upload succeeds. The digest is persisted and
70
- // reused across retries/reload (re-signing a fresh challenge is free); cleared on success.
71
- // 2. Walrus register: a registered-but-not-uploaded blob is resumed by its persisted register
72
- // digest (matched to the re-selected file by content hash), skipping the register transaction
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
- // Resume the register step iff a digest was saved for THIS exact file (survives reload).
110
- const resumeRegisterDigest = loadRegisterResume(storage, regKey, key) ?? undefined
111
- try {
112
- const r = await runBlobUpload({
113
- bytes,
114
- network: NETWORK,
115
- relayHost: opts.relayHost,
116
- address,
117
- wasmUrl: walrusWasmUrl,
118
- maxTipMist: uploadRelayMaxTipMist(),
119
- epochs: MAX_SINGLE_RESERVATION_EPOCHS,
120
- executor,
121
- suiClient: getSuiClient(),
122
- authToken,
123
- onStatus: opts.onStatus,
124
- resumeRegisterDigest,
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 — resuming the registered blob if one is still saved for this file.
123
+ // use, and retry.
146
124
  storage.removeItem(consumeKey as string)
147
125
  return await runOnce(await token(true))
148
126
  }
149
- throw e // keep the saved register digest so a manual retry / reload resumes
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 use before the file is stored — make the "attempt, not 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="use-notice">
220
- Uploading spends <strong>one use</strong> of your access NFT (an on-chain step) before
221
- the file is stored — it pays for an upload <em>attempt</em>, not a guaranteed upload.
222
- Your attempt resumes automatically, even after a page reload if you re-select the same
223
- file, so a use is normally not lost. A use is spent without a completed upload only if
224
- you abandon the upload entirely, cancel a required wallet approval, or wait long enough
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
- .use-notice {
294
+ .credit-notice {
317
295
  margin: 1rem 0;
318
296
  font-size: 0.85rem;
319
297
  line-height: 1.5;
@@ -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. When resuming without a prior `register()` call on
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 resumed
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. Encoding is always performed
78
- * (client-side, no gas); when `resumeRegisterDigest` is supplied the on-chain register transaction
79
- * is skipped and the upload proceeds against the already-registered blob. Up to two wallet approvals
80
- * (register — skipped on resume — and certify) are requested via `executor`; `onStatus` narrates.
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
- let registerDigest: string
105
- if (deps.resumeRegisterDigest) {
106
- // Resume: the blob was registered in a prior attempt. Skip the register transaction (no new
107
- // WAL/gas) and upload against the existing on-chain registration.
108
- registerDigest = deps.resumeRegisterDigest
109
- } else {
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: registerDigest, deletable: false })
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()