@meddleware/walrus-ui 0.1.22 → 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.22",
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",
@@ -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'
@@ -61,33 +56,15 @@ async function onPurchase(): Promise<void> {
61
56
  }
62
57
  }
63
58
 
64
- // On-chain resume discovery: given the encoded blobId, find an already-registered, uncertified blob
65
- // owned by `owner` and return its register digest, so an interrupted upload can resume WITHOUT a
66
- // local pointer (robust to cache-clear / new device / incognito). Best-effort: any failure falls
67
- // through to a fresh register. The walrus client is loaded lazily to keep it out of the eager graph.
68
- async function discoverRegistration(owner: string, blobId: string): Promise<string | undefined> {
69
- try {
70
- const { createWalrusClient, findUncertifiedRegisteredBlob } = await import('@meddleware/walrus-client')
71
- const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
72
- const found = await findUncertifiedRegisteredBlob(getSuiClient(), walrusClient, owner, blobId)
73
- return found?.registerDigest
74
- } catch {
75
- return undefined
76
- }
77
- }
78
-
79
59
  // Wire the shared WalrusUpload widget to the extracted upload orchestration + the wallet. The
80
60
  // register/upload/certify sequence lives in src/upload-flow.ts (unit-tested); this closure gathers
81
- // the wallet-bound inputs and manages two resume layers so an interrupted upload wastes nothing —
82
- // both persisted in localStorage (fast path) and rediscoverable on-chain (fallback), so they
83
- // survive a page reload and even a cache-clear once the same file is re-selected:
84
- // 1. Single-use consume: the relay treats the permanent on-chain `consumeDigest` as the one-time
85
- // redemption token, so a use is only spent when an upload succeeds. The digest is persisted and
86
- // reused across retries/reload (re-signing a fresh challenge is free); cleared on success.
87
- // 2. Walrus register: a registered-but-not-uploaded blob is resumed by its persisted register
88
- // digest (matched to the re-selected file by content hash), skipping the register transaction
89
- // (no new WAL/gas). Cleared on success, or when a resumed attempt itself fails (fall back to a
90
- // 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".
91
68
  async function performUpload(
92
69
  bytes: Uint8Array,
93
70
  opts: { relayHost: string; onStatus: (s: string) => void },
@@ -95,12 +72,10 @@ async function performUpload(
95
72
  if (!account.value) throw new Error('Connect your wallet first.')
96
73
  const executor = await buildExecutor()
97
74
  const address = account.value.address
98
- const key = contentKey(bytes)
99
75
  const storage = window.localStorage
100
76
 
101
77
  const gated = !!(gate && gateState.hasAccess.value === true && gateState.nftId.value)
102
78
  const consumeKey = gate ? consumeStorageKey(NETWORK, gate.gateId, address) : null
103
- const regKey = registerStorageKey(NETWORK, address)
104
79
 
105
80
  // Resolve this attempt's relay token (gated only); reuses a stored consume, fresh challenge each time.
106
81
  const token = (forceFresh: boolean): Promise<string | undefined> =>
@@ -122,38 +97,22 @@ async function performUpload(
122
97
  })
123
98
 
124
99
  const runOnce = async (authToken: string | undefined): Promise<UploadResult> => {
125
- // Resume the register step iff a digest was saved for THIS exact file (survives reload).
126
- const resumeRegisterDigest = loadRegisterResume(storage, regKey, key) ?? undefined
127
- try {
128
- const r = await runBlobUpload({
129
- bytes,
130
- network: NETWORK,
131
- relayHost: opts.relayHost,
132
- address,
133
- wasmUrl: walrusWasmUrl,
134
- maxTipMist: uploadRelayMaxTipMist(),
135
- epochs: MAX_SINGLE_RESERVATION_EPOCHS,
136
- executor,
137
- suiClient: getSuiClient(),
138
- authToken,
139
- onStatus: opts.onStatus,
140
- resumeRegisterDigest,
141
- // On-chain fallback when the local pointer is missing (cache-clear / new device): find an
142
- // already-registered, uncertified blob for this content and resume from it — gas-free.
143
- discoverRegisterDigest: (blobId) => discoverRegistration(address, blobId),
144
- onRegistered: (digest) => saveRegisterResume(storage, regKey, key, digest),
145
- })
146
- // Success → clear both resume layers (the use is now genuinely spent for an upload).
147
- clearRegisterResume(storage, regKey)
148
- if (consumeKey) storage.removeItem(consumeKey)
149
- return r
150
- } catch (e) {
151
- // A resumed attempt that fails drops the saved register digest so the next try does a full
152
- // fresh register (never worse than today). A non-resumed failure keeps the digest that
153
- // onRegistered saved, so the next try (or a reload + re-select) resumes without re-registering.
154
- if (resumeRegisterDigest !== undefined) clearRegisterResume(storage, regKey)
155
- throw e
156
- }
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
157
116
  }
158
117
 
159
118
  try {
@@ -161,11 +120,11 @@ async function performUpload(
161
120
  } catch (e) {
162
121
  if (gated && isRedeemedConflict(e)) {
163
122
  // Stored consume already redeemed (a prior upload actually landed): clear it, spend a fresh
164
- // use, and retry — resuming the registered blob if one is still saved for this file.
123
+ // use, and retry.
165
124
  storage.removeItem(consumeKey as string)
166
125
  return await runOnce(await token(true))
167
126
  }
168
- throw e // keep the saved register digest so a manual retry / reload resumes
127
+ throw e
169
128
  }
170
129
  }
171
130
 
@@ -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,13 +32,11 @@ interface BuiltTx {
25
32
  }
26
33
 
27
34
  export interface BlobUploadFlow {
28
- // The SDK `encode()` returns a `WriteBlobStepEncoded`; we use its deterministic `blobId` to look
29
- // up an existing on-chain registration for the same content (resume discovery).
30
- encode(): Promise<{ blobId: string }>
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.
37
+ encode(): Promise<void>
31
38
  register(opts: { owner: string; epochs: number; deletable: boolean }): BuiltTx
32
- // `digest` = the register transaction digest. When resuming without a prior `register()` call on
33
- // this flow instance, the SDK accepts the digest + `deletable` to upload against the already
34
- // registered blob (see `@mysten/walrus` WriteBlobFlowUploadOptions).
39
+ // `digest` = the register transaction digest produced by the register tx executed this attempt.
35
40
  upload(opts: { digest: string; deletable?: boolean }): Promise<void>
36
41
  certify(): BuiltTx
37
42
  getBlob(): Promise<{ blobId: string }>
@@ -53,40 +58,20 @@ export interface RunBlobUploadDeps {
53
58
  /** A Sui client used to `build()` the register/certify transactions. */
54
59
  suiClient: unknown
55
60
  /**
56
- * 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
57
62
  * upload presents a fresh challenge signature (see `@meddleware/walrus-client`).
58
63
  */
59
64
  authToken?: string | (() => string | undefined)
60
65
  onStatus: (s: string) => void
61
66
  /** Lazy loader for the Walrus client module (keeps wasm out of the eager bundle). */
62
67
  loadWalrusClient?: () => Promise<WalrusClientModule>
63
- /**
64
- * Resume from a blob registered in a PRIOR attempt (this session or a previous page load) by
65
- * passing its register transaction digest. The file is re-encoded (client-side, no gas) but the
66
- * on-chain `register` transaction is skipped — so an interrupted upload never re-registers (no new
67
- * WAL/gas), even across a reload once the same file is re-selected. Omit for a fresh upload.
68
- */
69
- resumeRegisterDigest?: string
70
- /**
71
- * On-chain resume fallback: called with the encoded `blobId` when no `resumeRegisterDigest` was
72
- * supplied. Returns the register digest of an already-registered, uncertified on-chain blob for
73
- * this content (or `undefined`). This makes resume robust to a lost local pointer (cache-clear /
74
- * new device) — the registration is discovered on-chain rather than remembered client-side.
75
- */
76
- discoverRegisterDigest?: (blobId: string) => Promise<string | undefined>
77
- /**
78
- * Called with the register transaction digest immediately after a fresh register succeeds, so the
79
- * caller can PERSIST it (e.g. to localStorage) and resume the upload after a failure/reload
80
- * without re-registering.
81
- */
82
- onRegistered?: (registerDigest: string) => void
83
68
  }
84
69
 
85
70
  /**
86
- * Register → upload → certify a blob and resolve its id + public URL. Encoding is always performed
87
- * (client-side, no gas); when `resumeRegisterDigest` is supplied the on-chain register transaction
88
- * is skipped and the upload proceeds against the already-registered blob. Up to two wallet approvals
89
- * (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.
90
75
  */
91
76
  export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResult> {
92
77
  // The real module's flow types are richer than the narrow structural subset we use here, so the
@@ -105,33 +90,18 @@ export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResu
105
90
  })
106
91
  const flow = createBlobUploadFlow(client, deps.bytes)
107
92
 
108
- // Encoding is deterministic from the content and costs no gas, so it always runs — including on a
109
- // resume, where it re-derives the slivers for the re-selected file (and yields the blobId used
110
- // for on-chain resume discovery).
111
93
  deps.onStatus('Encoding…')
112
- const { blobId } = await flow.encode()
113
-
114
- // Resolve a resume point: a caller-provided digest (localStorage fast path) or, failing that, an
115
- // on-chain lookup by blobId (robust to a lost local pointer). Either skips the register tx.
116
- let registerDigest = deps.resumeRegisterDigest
117
- if (registerDigest === undefined && deps.discoverRegisterDigest) {
118
- registerDigest = (await deps.discoverRegisterDigest(blobId)) ?? undefined
119
- }
94
+ await flow.encode()
120
95
 
121
- if (registerDigest === undefined) {
122
- deps.onStatus('Registering blob (approve in wallet)…')
123
- const regTx = flow.register({ owner: deps.address, epochs: deps.epochs, deletable: false })
124
- regTx.setSenderIfNotSet(deps.address)
125
- await regTx.build({ client: deps.suiClient })
126
- const reg = await deps.executor.signAndExecute(regTx)
127
- await deps.executor.waitForTransaction(reg.digest)
128
- registerDigest = reg.digest
129
- // Persist point: hand back the digest so the upload can be resumed after a failure/reload.
130
- deps.onRegistered?.(registerDigest)
131
- }
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)
132
102
 
133
103
  deps.onStatus('Uploading to the relay…')
134
- await flow.upload({ digest: registerDigest, deletable: false })
104
+ await flow.upload({ digest: reg.digest, deletable: false })
135
105
 
136
106
  deps.onStatus('Certifying (approve in wallet)…')
137
107
  const certTx = flow.certify()