@meddleware/walrus-ui 0.1.17 → 0.1.19

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.17",
3
+ "version": "0.1.19",
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",
@@ -42,9 +42,9 @@
42
42
  "dependencies": {
43
43
  "@meddleware/design-tokens": "^0.1.2",
44
44
  "@meddleware/nft-gate-client": "^0.0.6",
45
- "@meddleware/ui": "^0.1.9",
45
+ "@meddleware/ui": "^0.1.10",
46
46
  "@meddleware/wallet-adapter": "^0.0.5",
47
- "@meddleware/walrus-client": "^0.0.7",
47
+ "@meddleware/walrus-client": "^0.0.8",
48
48
  "@meddleware/walrus-relay": "^0.1.8",
49
49
  "@mysten/sui": "^2.30.0",
50
50
  "@mysten/wallet-standard": "^0.20.0",
@@ -0,0 +1,108 @@
1
+ // Consume-persistence + resume for the single-use NFT relay paywall.
2
+ //
3
+ // The relay consumes one NFT use BEFORE the upload (the gateway requires an on-chain
4
+ // AccessConsumedEvent before it will proxy). To ensure an interrupted upload never burns a use,
5
+ // the gateway treats the permanent on-chain `consumeDigest` as the one-time redemption token: a
6
+ // use is only spent when an upload succeeds. This module is the client half — it persists the
7
+ // consumeDigest so a reload/retry reuses the SAME consume (re-signing a fresh challenge is free)
8
+ // instead of consuming another use, and clears it once an upload succeeds.
9
+ //
10
+ // Extracted from WalrusView.vue so the resume decision is unit-testable with a fake storage.
11
+
12
+ /** The subset of the Web Storage API this module needs (injectable for tests). */
13
+ export interface StorageLike {
14
+ getItem(key: string): string | null
15
+ setItem(key: string, value: string): void
16
+ removeItem(key: string): void
17
+ }
18
+
19
+ /** Stable per-(network, gate, address) key under which the pending consumeDigest is stored. */
20
+ export function consumeStorageKey(network: string, gateId: string, address: string): string {
21
+ return `mw:walrus:consume:${network}:${gateId}:${address}`
22
+ }
23
+
24
+ /**
25
+ * True if `err` is the gateway's "this consume was already redeemed" rejection (HTTP 409 with
26
+ * `code: 'redeemed'`). Distinguished from transient failures so we only re-consume (spend a new
27
+ * use) when the stored digest is genuinely spent — never on a network blip.
28
+ */
29
+ export function isRedeemedConflict(err: unknown): boolean {
30
+ const e = err as { status?: number; error?: { code?: string } } | null
31
+ return !!e && e.status === 409 && e.error?.code === 'redeemed'
32
+ }
33
+
34
+ /** A challenge carrying at least a `nonce` (structural; the full object is passed through). */
35
+ export interface ChallengeLike {
36
+ nonce: string
37
+ }
38
+
39
+ /**
40
+ * Injected dependencies for {@link resolveGatedAuthToken} (all wallet/relay/chain calls).
41
+ * Generic over the challenge (`C`), transaction (`Tx`), and signer (`S`) types so the wallet's
42
+ * concrete `PersonalMessageSigner` flows through to `buildAccessProof` without widening.
43
+ */
44
+ export interface ResolveTokenDeps<C extends ChallengeLike, Tx, S> {
45
+ storage: StorageLike
46
+ key: string
47
+ relayHost: string
48
+ address: string
49
+ nftId: string
50
+ /** Fetch a fresh challenge nonce from the gateway. */
51
+ fetchChallenge: (relayHost: string) => Promise<C>
52
+ /** Build the on-chain `access_gate::consume` PTB for `nftId` + `nonce`. */
53
+ buildConsume: (nftId: string, nonce: string) => Tx
54
+ /** Sign + execute a PTB; resolves with the transaction digest. */
55
+ signAndExecute: (tx: Tx) => Promise<{ digest?: string }>
56
+ /** Wait for a transaction to finalise (best-effort; errors are swallowed by the caller). */
57
+ waitForTransaction: (digest: string) => Promise<unknown>
58
+ /** Build the base64 relay access-proof token. */
59
+ buildAccessProof: (args: {
60
+ address: string
61
+ challenge: C
62
+ sign: S
63
+ consumeDigest?: string
64
+ }) => Promise<string>
65
+ /** Personal-message signer (free — no gas, no use). */
66
+ sign: S
67
+ /** Ignore any stored digest and consume a fresh use (used after a `redeemed` conflict). */
68
+ forceFresh?: boolean
69
+ }
70
+
71
+ /**
72
+ * Resolve the relay auth token for a gated upload, reusing a persisted consume when present.
73
+ *
74
+ * - A stored (unspent) digest ⇒ reuse it: fetch a fresh challenge, sign (free), no new consume.
75
+ * - Otherwise ⇒ consume one use on-chain and persist the digest BEFORE the upload, so an
76
+ * interruption after this point resumes rather than re-consuming.
77
+ *
78
+ * The caller must clear the stored key on a successful upload and may retry with `forceFresh`
79
+ * after {@link isRedeemedConflict}.
80
+ */
81
+ export async function resolveGatedAuthToken<C extends ChallengeLike, Tx, S>(
82
+ deps: ResolveTokenDeps<C, Tx, S>,
83
+ ): Promise<string> {
84
+ const stored = deps.forceFresh ? null : deps.storage.getItem(deps.key)
85
+ const challenge = await deps.fetchChallenge(deps.relayHost)
86
+
87
+ let consumeDigest: string | undefined
88
+ if (stored) {
89
+ // Resume: reuse the already-consumed use; the fresh challenge is only for signature freshness.
90
+ consumeDigest = stored
91
+ } else {
92
+ const consumeTx = deps.buildConsume(deps.nftId, challenge.nonce)
93
+ const res = await deps.signAndExecute(consumeTx)
94
+ consumeDigest = res.digest
95
+ // Persist BEFORE the upload so a crash/reload between consume and upload success can resume.
96
+ if (consumeDigest) {
97
+ deps.storage.setItem(deps.key, consumeDigest)
98
+ await deps.waitForTransaction(consumeDigest).catch(() => {})
99
+ }
100
+ }
101
+
102
+ return deps.buildAccessProof({
103
+ address: deps.address,
104
+ challenge,
105
+ sign: deps.sign,
106
+ consumeDigest,
107
+ })
108
+ }
@@ -1,9 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import { onMounted, ref, watch } from 'vue'
3
+ import { CopyableAddress, ExplorerLink } from '@meddleware/ui'
3
4
  import type { OwnedBlob } from '@meddleware/walrus-client'
4
5
  import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
5
6
  import type { Executor } from '../wallet.js'
6
- import { NETWORK } from '../config.js'
7
+ import { NETWORK, walruscanBlobUrl } from '../config.js'
7
8
  import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
8
9
 
9
10
  const props = defineProps<{
@@ -92,7 +93,15 @@ watch(() => props.address, (addr) => void load(addr))
92
93
  :key="blob.objectId"
93
94
  :class="{ warn: blob.endEpoch - currentEpoch < EXPIRY_WARN_EPOCHS }"
94
95
  >
95
- <td class="mono">{{ blob.blobId.slice(0, 12) }}…</td>
96
+ <td>
97
+ <CopyableAddress :address="blob.blobId" label="Copy blob ID">
98
+ <ExplorerLink
99
+ :href="walruscanBlobUrl(NETWORK, blob.blobId)"
100
+ :value="blob.blobId"
101
+ :chars="[8, 6]"
102
+ />
103
+ </CopyableAddress>
104
+ </td>
96
105
  <td>{{ (blob.size / 1024).toFixed(1) }} KB</td>
97
106
  <td>
98
107
  epoch {{ blob.endEpoch }}
@@ -11,13 +11,15 @@ import {
11
11
  MAX_SINGLE_RESERVATION_EPOCHS,
12
12
  } from '@meddleware/walrus-relay'
13
13
  import type { UploadResult } from '@meddleware/walrus-relay'
14
+ import { CopyableAddress, ExplorerLink, suiExplorerUrl } from '@meddleware/ui'
14
15
  // Lightweight URL import — just the wasm asset URL (does not pull the walrus client).
15
16
  import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
16
17
  import { WalletGuard } from '@meddleware/wallet-adapter'
17
18
  import { useWallet, getSuiClient } from '../wallet.js'
18
19
  import { fetchChallenge, buildAccessProof } from '@meddleware/nft-gate-client'
19
- import { NETWORK, relayHosts, accessGate, uploadRelayMaxTipMist } from '../config.js'
20
- import { runBlobUpload } from '../upload-flow.js'
20
+ import { NETWORK, relayHosts, accessGate, uploadRelayMaxTipMist, walruscanBlobUrl } from '../config.js'
21
+ import { runBlobUpload, type UploadResumeState } from '../upload-flow.js'
22
+ import { consumeStorageKey, isRedeemedConflict, resolveGatedAuthToken } from '../access-resume.js'
21
23
  import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
22
24
  import MyBlobs from './MyBlobs.vue'
23
25
 
@@ -50,49 +52,116 @@ async function onPurchase(): Promise<void> {
50
52
  }
51
53
  }
52
54
 
55
+ // The relay auth token for the current attempt. The walrus client reads it PER REQUEST (a provider),
56
+ // so a retained flow resumed after a failure presents a fresh challenge signature. Undefined ⇒
57
+ // ungated (no header).
58
+ const authTokenRef = ref<string | undefined>(undefined)
59
+ // A same-session registered blob retained after a failed upload, keyed by file content, so a retry
60
+ // resumes from the relay upload instead of re-registering (no new WAL/gas). Cleared on success or
61
+ // when a resumed attempt itself fails (fall back to a fresh full upload).
62
+ const uploadSession = ref<{ key: string; state: UploadResumeState } | null>(null)
63
+
64
+ /** Cheap content key (length + head/tail bytes) to match a retry to the same selected file. */
65
+ function contentKey(bytes: Uint8Array): string {
66
+ const head = Array.from(bytes.slice(0, 16)).join(',')
67
+ const tail = Array.from(bytes.slice(-16)).join(',')
68
+ return `${bytes.length}:${head}:${tail}`
69
+ }
70
+
53
71
  // Wire the shared WalrusUpload widget to the extracted upload orchestration + the wallet. The
54
- // register/upload/certify sequence lives in src/upload-flow.ts (unit-tested); this closure only
55
- // gathers the wallet-bound inputs (executor, sui client, gated proof token) and delegates.
72
+ // register/upload/certify sequence lives in src/upload-flow.ts (unit-tested); this closure gathers
73
+ // the wallet-bound inputs and manages two resume layers so an interrupted upload wastes nothing:
74
+ // 1. Single-use consume: the relay treats the permanent on-chain `consumeDigest` as the one-time
75
+ // redemption token, so a use is only spent when an upload succeeds. The digest is persisted and
76
+ // reused across retries/reload (re-signing a fresh challenge is free); cleared on success.
77
+ // 2. Walrus flow (same session): a registered-but-not-uploaded blob is retained and reused on a
78
+ // retry of the same file, skipping re-encode/re-register (no new WAL/gas).
56
79
  async function performUpload(
57
80
  bytes: Uint8Array,
58
81
  opts: { relayHost: string; onStatus: (s: string) => void },
59
82
  ): Promise<UploadResult> {
60
83
  if (!account.value) throw new Error('Connect your wallet first.')
61
84
  const executor = await buildExecutor()
85
+ const address = account.value.address
86
+ const key = contentKey(bytes)
62
87
 
63
- // If the relay is NFT-gated and we hold access, run the SINGLE_USE consume flow:
64
- // fetch a challenge nonce, execute the on-chain access_gate::consume tx to record
65
- // use of that nonce (produces a consumeDigest), then sign a proof token that
66
- // includes both. SINGLE_USE=true gateways reject proofs without a consumeDigest.
67
- // TODO: replace this block with `gateState.consumeAndBuildToken(...)` once
68
- // @meddleware/walrus-relay 0.1.6 is installed.
69
- let authToken: string | undefined
70
- if (gate && gateState.hasAccess.value === true && gateState.nftId.value) {
71
- const challenge = await fetchChallenge(opts.relayHost)
72
- const consumeTx = gateState.buildConsume(gateState.nftId.value, challenge.nonce)
73
- const consumeResult = await executor.signAndExecute(consumeTx)
74
- if (consumeResult.digest) await executor.waitForTransaction(consumeResult.digest).catch(() => {})
75
- authToken = await buildAccessProof({
76
- address: account.value.address,
77
- challenge,
88
+ const gated = !!(gate && gateState.hasAccess.value === true && gateState.nftId.value)
89
+ const consumeKey = gate ? consumeStorageKey(NETWORK, gate.gateId, address) : null
90
+
91
+ // Resolve this attempt's relay token (gated only) and stash it where the client reads it.
92
+ async function setToken(forceFresh: boolean): Promise<void> {
93
+ if (!gated) {
94
+ authTokenRef.value = undefined
95
+ return
96
+ }
97
+ authTokenRef.value = await resolveGatedAuthToken({
98
+ storage: window.localStorage,
99
+ key: consumeKey as string,
100
+ relayHost: opts.relayHost,
101
+ address,
102
+ nftId: gateState.nftId.value as string,
103
+ fetchChallenge,
104
+ buildConsume: (id, nonce) => gateState.buildConsume(id, nonce),
105
+ signAndExecute: (tx) => executor.signAndExecute(tx),
106
+ waitForTransaction: (digest) => executor.waitForTransaction(digest),
107
+ buildAccessProof,
78
108
  sign: signPersonalMessage,
79
- consumeDigest: consumeResult.digest,
109
+ forceFresh,
80
110
  })
81
111
  }
82
112
 
83
- return runBlobUpload({
113
+ const deps = (resume?: UploadResumeState) => ({
84
114
  bytes,
85
115
  network: NETWORK,
86
116
  relayHost: opts.relayHost,
87
- address: account.value.address,
117
+ address,
88
118
  wasmUrl: walrusWasmUrl,
89
119
  maxTipMist: uploadRelayMaxTipMist(),
90
120
  epochs: MAX_SINGLE_RESERVATION_EPOCHS,
91
121
  executor,
92
122
  suiClient: getSuiClient(),
93
- authToken,
123
+ // Provider: resolved per request so a resumed flow uses the fresh token.
124
+ authToken: () => authTokenRef.value,
94
125
  onStatus: opts.onStatus,
126
+ resume,
127
+ onRegistered: (state: UploadResumeState) => {
128
+ uploadSession.value = { key, state }
129
+ },
95
130
  })
131
+
132
+ const succeed = (r: UploadResult): UploadResult => {
133
+ uploadSession.value = null
134
+ if (consumeKey) window.localStorage.removeItem(consumeKey) // use spent only on success
135
+ return r
136
+ }
137
+
138
+ // Reuse a retained registration for this exact file, if any.
139
+ const resumeFor = () => (uploadSession.value?.key === key ? uploadSession.value.state : undefined)
140
+ const wasResuming = resumeFor() !== undefined
141
+
142
+ await setToken(false)
143
+ try {
144
+ return succeed(await runBlobUpload(deps(resumeFor())))
145
+ } catch (e) {
146
+ // A resumed attempt failed → drop the retained registration so the next try does a full fresh
147
+ // upload (re-register), guaranteeing behaviour no worse than a non-resumed run.
148
+ if (wasResuming) uploadSession.value = null
149
+
150
+ if (gated && isRedeemedConflict(e)) {
151
+ // Stored consume already redeemed (a prior upload actually landed): clear it, spend a fresh
152
+ // use, and retry — resuming the registered blob if we still hold it.
153
+ window.localStorage.removeItem(consumeKey as string)
154
+ await setToken(true)
155
+ const resume2 = resumeFor()
156
+ try {
157
+ return succeed(await runBlobUpload(deps(resume2)))
158
+ } catch (e2) {
159
+ if (resume2) uploadSession.value = null
160
+ throw e2
161
+ }
162
+ }
163
+ throw e // keep the retained registration so a manual retry resumes
164
+ }
96
165
  }
97
166
 
98
167
  const ownedBlobs = useOwnedBlobs()
@@ -137,32 +206,65 @@ function onSettled(): void {
137
206
 
138
207
  <WalletGuard message="Connect a Sui wallet to upload and manage your blobs.">
139
208
  <template v-if="activeTab === 'upload'">
140
- <AccessGateCta
141
- :gate-configured="gateState.gateConfigured"
142
- :has-access="gateState.hasAccess.value"
143
- :busy="purchasing"
144
- :price-mist="gate?.priceMist ?? null"
145
- @purchase="onPurchase"
146
- />
147
-
148
- <WalrusUpload
149
- :hosts="relayHosts(NETWORK)"
150
- :connected="!!account"
151
- :access="{ gateConfigured: gateState.gateConfigured, hasAccess: gateState.hasAccess }"
152
- :perform-upload="performUpload"
153
- @uploaded="onUploaded"
154
- @settled="onSettled"
155
- />
156
-
157
- <section v-if="result" class="result">
158
- <h2>Uploaded ✓</h2>
159
- <p><strong>Blob ID:</strong> <code>{{ result.blobId }}</code></p>
160
- <p>
161
- <strong>URL:</strong>
162
- <a :href="result.url" target="_blank" rel="noopener">{{ result.url }}</a>
163
- </p>
164
- <p v-if="result.digest"><strong>Certify tx:</strong> <code>{{ result.digest }}</code></p>
165
- </section>
209
+ <!-- Gated + no access: replace the form with the purchase CTA so the user is guided to buy
210
+ first, rather than facing a disabled form. -->
211
+ <div v-if="gateState.gateConfigured && gateState.hasAccess.value === false" class="gate-card">
212
+ <AccessGateCta
213
+ :gate-configured="gateState.gateConfigured"
214
+ :has-access="gateState.hasAccess.value"
215
+ :busy="purchasing"
216
+ :price-mist="gate?.priceMist ?? null"
217
+ @purchase="onPurchase"
218
+ />
219
+ </div>
220
+
221
+ <!-- Ownership check still in flight. -->
222
+ <p
223
+ v-else-if="gateState.gateConfigured && gateState.hasAccess.value === null"
224
+ class="checking"
225
+ >
226
+ Checking access…
227
+ </p>
228
+
229
+ <!-- Access held (or ungated relay): show the upload form. -->
230
+ <template v-else>
231
+ <WalrusUpload
232
+ :hosts="relayHosts(NETWORK)"
233
+ :connected="!!account"
234
+ :access="{ gateConfigured: gateState.gateConfigured, hasAccess: gateState.hasAccess }"
235
+ :perform-upload="performUpload"
236
+ @uploaded="onUploaded"
237
+ @settled="onSettled"
238
+ />
239
+
240
+ <section v-if="result" class="result">
241
+ <h2>Uploaded ✓</h2>
242
+ <p>
243
+ <strong>Blob ID:</strong>
244
+ <CopyableAddress :address="result.blobId" label="Copy blob ID">
245
+ <ExplorerLink
246
+ :href="walruscanBlobUrl(NETWORK, result.blobId)"
247
+ :value="result.blobId"
248
+ :chars="[8, 6]"
249
+ />
250
+ </CopyableAddress>
251
+ </p>
252
+ <p>
253
+ <strong>URL:</strong>
254
+ <a :href="result.url" target="_blank" rel="noopener">{{ result.url }}</a>
255
+ </p>
256
+ <p v-if="result.digest">
257
+ <strong>Certify tx:</strong>
258
+ <CopyableAddress :address="result.digest" label="Copy transaction digest">
259
+ <ExplorerLink
260
+ :href="suiExplorerUrl('txblock', result.digest, NETWORK)"
261
+ :value="result.digest"
262
+ :chars="[8, 6]"
263
+ />
264
+ </CopyableAddress>
265
+ </p>
266
+ </section>
267
+ </template>
166
268
  </template>
167
269
 
168
270
  <MyBlobs
@@ -199,6 +301,21 @@ function onSettled(): void {
199
301
  font-size: 0.85rem;
200
302
  }
201
303
 
304
+ .gate-card {
305
+ margin-top: 1rem;
306
+ padding: 1.5rem;
307
+ border: 1px solid var(--border);
308
+ border-radius: 12px;
309
+ background: var(--surface, transparent);
310
+ text-align: center;
311
+ }
312
+
313
+ .checking {
314
+ margin-top: 1.5rem;
315
+ color: var(--muted);
316
+ text-align: center;
317
+ }
318
+
202
319
  .tabs {
203
320
  display: flex;
204
321
  gap: 0.25rem;
@@ -34,11 +34,14 @@ async function load(address: string | null, opts: { force?: boolean } = {}): Pro
34
34
  const { createWalrusClient, fetchOwnedWalrusBlobs } = await import('@meddleware/walrus-client')
35
35
  const suiClient = getSuiClient()
36
36
  const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
37
+ // Blob `endEpoch` is a WALRUS epoch, so compare against the Walrus committee epoch — NOT the
38
+ // Sui system-state epoch (they are different clocks; the Sui epoch, ~1218 vs ~570, made every
39
+ // blob read as "expired").
37
40
  const [sys, fetched] = await Promise.all([
38
- suiClient.getCurrentSystemState(),
41
+ walrusClient.walrus.systemState(),
39
42
  fetchOwnedWalrusBlobs(suiClient, walrusClient, address),
40
43
  ])
41
- currentEpoch.value = Number(sys.systemState.epoch)
44
+ currentEpoch.value = Number(sys.committee.epoch)
42
45
  blobs.value = fetched.sort((a: OwnedBlob, b: OwnedBlob) => a.endEpoch - b.endEpoch)
43
46
  loadedFor.value = address
44
47
  } catch (e) {
package/src/config.ts CHANGED
@@ -34,6 +34,14 @@ export function relayHosts(network: WalrusNetwork): { operator: string; public:
34
34
  return { operator: OPERATOR_RELAY_HOSTS[network], public: PUBLIC_WALRUS_RELAY_HOSTS[network] }
35
35
  }
36
36
 
37
+ /**
38
+ * Walruscan explorer URL for a blob. A Walrus blob id is not a Sui object, so it links to the
39
+ * Walrus-native explorer rather than a Sui explorer (SuiVision handles Sui entities elsewhere).
40
+ */
41
+ export function walruscanBlobUrl(network: WalrusNetwork, blobId: string): string {
42
+ return `https://walruscan.com/${network}/blob/${blobId}`
43
+ }
44
+
37
45
  /** Default relay tip ceiling (MIST) when `VITE_UPLOAD_RELAY_MAX_TIP_MIST` is unset (0.5 SUI). */
38
46
  export const DEFAULT_UPLOAD_RELAY_MAX_TIP_MIST = 500_000_000
39
47
 
@@ -24,7 +24,7 @@ interface BuiltTx {
24
24
  build(opts: { client: unknown }): Promise<unknown>
25
25
  }
26
26
 
27
- interface BlobUploadFlow {
27
+ export interface BlobUploadFlow {
28
28
  encode(): Promise<void>
29
29
  register(opts: { owner: string; epochs: number; deletable: boolean }): BuiltTx
30
30
  upload(opts: { digest: string }): Promise<void>
@@ -32,6 +32,17 @@ interface BlobUploadFlow {
32
32
  getBlob(): Promise<{ blobId: string }>
33
33
  }
34
34
 
35
+ /**
36
+ * A same-session resume point: a flow whose blob is already registered on-chain, plus the register
37
+ * transaction digest. Passing this to {@link runBlobUpload} skips encode + register (no new WAL/gas)
38
+ * and retries from the relay upload — so an interrupted upload never re-registers. Not serialisable
39
+ * / not for cross-reload use (the flow holds the encoded blob in memory).
40
+ */
41
+ export interface UploadResumeState {
42
+ flow: BlobUploadFlow
43
+ registerDigest: string
44
+ }
45
+
35
46
  export interface RunBlobUploadDeps {
36
47
  bytes: Uint8Array
37
48
  network: string
@@ -47,11 +58,24 @@ export interface RunBlobUploadDeps {
47
58
  executor: UploadExecutor
48
59
  /** A Sui client used to `build()` the register/certify transactions. */
49
60
  suiClient: unknown
50
- /** Optional Bearer proof token for an NFT-gated relay. */
51
- authToken?: string
61
+ /**
62
+ * Bearer proof token for an NFT-gated relay. May be a provider resolved per request so a resumed
63
+ * upload presents a fresh challenge signature (see `@meddleware/walrus-client`).
64
+ */
65
+ authToken?: string | (() => string | undefined)
52
66
  onStatus: (s: string) => void
53
67
  /** Lazy loader for the Walrus client module (keeps wasm out of the eager bundle). */
54
68
  loadWalrusClient?: () => Promise<WalrusClientModule>
69
+ /**
70
+ * Resume a prior same-session upload from its registered blob (skips encode + register). Omit for
71
+ * a fresh upload.
72
+ */
73
+ resume?: UploadResumeState
74
+ /**
75
+ * Called once the blob is registered (fresh uploads only), handing back the flow + register digest
76
+ * so the caller can retain them and resume the relay upload after a failure without re-registering.
77
+ */
78
+ onRegistered?: (state: UploadResumeState) => void
55
79
  }
56
80
 
57
81
  /**
@@ -66,27 +90,41 @@ export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResu
66
90
  (async () => (await import('@meddleware/walrus-client')) as unknown as WalrusClientModule)
67
91
  const { createWalrusClient, createBlobUploadFlow, walrusBlobUrl } = await load()
68
92
 
69
- const client = createWalrusClient({
70
- network: deps.network,
71
- wasmUrl: deps.wasmUrl,
72
- uploadRelayHost: deps.relayHost,
73
- uploadRelayAuthToken: deps.authToken,
74
- uploadRelayMaxTipMist: deps.maxTipMist,
75
- })
76
- const flow = createBlobUploadFlow(client, deps.bytes)
93
+ let flow: BlobUploadFlow
94
+ let registerDigest: string
95
+
96
+ if (deps.resume) {
97
+ // Same-session resume: the blob is already registered on-chain. Skip encode + register (no new
98
+ // WAL/gas) and retry from the relay upload. The retained flow keeps its registered state; its
99
+ // client resolves the relay token per request, so a fresh challenge is used on retry.
100
+ flow = deps.resume.flow
101
+ registerDigest = deps.resume.registerDigest
102
+ } else {
103
+ const client = createWalrusClient({
104
+ network: deps.network,
105
+ wasmUrl: deps.wasmUrl,
106
+ uploadRelayHost: deps.relayHost,
107
+ uploadRelayAuthToken: deps.authToken,
108
+ uploadRelayMaxTipMist: deps.maxTipMist,
109
+ })
110
+ flow = createBlobUploadFlow(client, deps.bytes)
77
111
 
78
- deps.onStatus('Encoding…')
79
- await flow.encode()
112
+ deps.onStatus('Encoding…')
113
+ await flow.encode()
80
114
 
81
- deps.onStatus('Registering blob (approve in wallet)…')
82
- const regTx = flow.register({ owner: deps.address, epochs: deps.epochs, deletable: false })
83
- regTx.setSenderIfNotSet(deps.address)
84
- await regTx.build({ client: deps.suiClient })
85
- const reg = await deps.executor.signAndExecute(regTx)
86
- await deps.executor.waitForTransaction(reg.digest)
115
+ deps.onStatus('Registering blob (approve in wallet)…')
116
+ const regTx = flow.register({ owner: deps.address, epochs: deps.epochs, deletable: false })
117
+ regTx.setSenderIfNotSet(deps.address)
118
+ await regTx.build({ client: deps.suiClient })
119
+ const reg = await deps.executor.signAndExecute(regTx)
120
+ await deps.executor.waitForTransaction(reg.digest)
121
+ registerDigest = reg.digest
122
+ // Hand the registered flow back so the caller can resume the relay upload after a failure.
123
+ deps.onRegistered?.({ flow, registerDigest })
124
+ }
87
125
 
88
126
  deps.onStatus('Uploading to the relay…')
89
- await flow.upload({ digest: reg.digest })
127
+ await flow.upload({ digest: registerDigest })
90
128
 
91
129
  deps.onStatus('Certifying (approve in wallet)…')
92
130
  const certTx = flow.certify()