@meddleware/walrus-ui 0.1.42 → 0.1.44

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/README.md CHANGED
@@ -31,19 +31,18 @@ All `VITE_*` vars are baked into the static bundle at build time.
31
31
 
32
32
  | Variable | Default | Description |
33
33
  | --- | --- | --- |
34
- | `VITE_NETWORK` | `testnet` | `testnet` or `mainnet` |
34
+ | `VITE_NETWORK` | `testnet` | Network the standalone build selects (`testnet` or `mainnet`); embedded, the host's selector rules |
35
35
  | `VITE_WALRUS_RELAY_TESTNET` | Public Mysten relay | Operator relay URL for testnet |
36
36
  | `VITE_WALRUS_RELAY_MAINNET` | Public Mysten relay | Operator relay URL for mainnet |
37
- | `VITE_RPC_TESTNET` | `https://sui-testnet-rpc.publicnode.com` | Sui RPC for testnet |
38
- | `VITE_RPC_MAINNET` | `https://fullnode.mainnet.sui.io:443` | Sui RPC for mainnet |
39
37
  | `VITE_ACCESS_GATE_ID_TESTNET` | — | Gate object ID (testnet; unset = no gate) |
40
38
  | `VITE_ACCESS_GATE_SOULBOUND_TESTNET` | `false` | `true` if NFTs are soulbound |
41
39
  | `VITE_ACCESS_GATE_PRICE_MIST_TESTNET` | `0` | Purchase price in MIST |
42
40
  | `VITE_ACCESS_GATE_ID_MAINNET` | — | Gate object ID (mainnet) |
43
41
  | `VITE_UPLOAD_RELAY_MAX_TIP_MIST` | `50000000` | Max relay tip cap in MIST (0.05 SUI) |
44
42
 
45
- `ACCESS_GATE_PACKAGE_ID` and `ACCESS_GATE_PLATFORM_CONFIG_ID` are hardcoded in
46
- `@meddleware/walrus-relay` — operators only configure the values listed above.
43
+ The `access_gate` package and `PlatformConfig` come from the published deployment
44
+ (`relayGateConfig` in `@meddleware/walrus-relay`) — operators only configure the values listed
45
+ above.
47
46
 
48
47
  ## Docker build
49
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meddleware/walrus-ui",
3
- "version": "0.1.42",
3
+ "version": "0.1.44",
4
4
  "homepage": "https://docs.meddleware.co.uk/blockchain/sui/walrus-storage/",
5
5
  "description": "Standalone Vue 3 SPA for uploading blobs to Walrus decentralised storage and managing owned blobs — wallet-connected, NFT-gated relay support.",
6
6
  "author": "Meddleware <dev@meddleware.co.uk>",
@@ -46,18 +46,21 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@meddleware/design-tokens": "^0.1.7",
49
- "@meddleware/nft-gate-client": "^0.0.12",
50
49
  "@meddleware/ui": "^0.1.28",
51
- "@meddleware/wallet-adapter": "^0.0.10",
52
- "@meddleware/walrus-client": "^0.0.18",
53
- "@meddleware/walrus-relay": "^0.1.19",
50
+ "@meddleware/walrus-client": "^0.0.21",
51
+ "@meddleware/walrus-relay": "^0.1.20",
54
52
  "@mysten/sui": "^2.33.1",
55
53
  "@mysten/wallet-standard": "^0.21.0",
56
- "@mysten/walrus": "~1.2.31",
54
+ "@mysten/walrus": "~1.2.32",
57
55
  "@mysten/walrus-wasm": "~0.3.1",
58
56
  "vue": "^3.5.43"
59
57
  },
58
+ "peerDependencies": {
59
+ "@meddleware/wallet-adapter": ">=0.0.12 <0.2.0"
60
+ },
60
61
  "devDependencies": {
62
+ "@meddleware/access-gate-client": "^0.0.1",
63
+ "@meddleware/wallet-adapter": "^0.0.12",
61
64
  "@playwright/test": "~1.63.0",
62
65
  "@types/node": "~24.12.2",
63
66
  "@vitejs/plugin-vue": "^6.0.8",
@@ -75,7 +78,7 @@
75
78
  "stylelint-config-standard": "^40.0.0",
76
79
  "typescript": "~6.0.0",
77
80
  "vite": "^8.3.0",
78
- "vitest": "~5.0.1",
81
+ "vitest": "~5.0.2",
79
82
  "vue-eslint-parser": "^10.4.1",
80
83
  "vue-tsc": "~3.3.0"
81
84
  },
package/src/App.vue CHANGED
@@ -3,7 +3,7 @@
3
3
  // The core UI lives in WalrusView.vue (also exported for inline embedding in the dashboard).
4
4
  import { AppHeader, AppFooter, ColorModeControl, useColorMode } from '@meddleware/ui'
5
5
  import { TipConfigBadge } from '@meddleware/walrus-relay'
6
- import { NETWORK, OPERATOR_RELAY_HOSTS } from './config.js'
6
+ import { walrusNetwork, OPERATOR_RELAY_HOSTS } from './config.js'
7
7
  import WalrusView from './components/WalrusView.vue'
8
8
 
9
9
  const { mode, set } = useColorMode('dark')
@@ -18,14 +18,14 @@ const DEV_URL = import.meta.env.VITE_DEV_URL || 'https://dev.meddleware.co.uk/
18
18
  <h1 class="brand-title">Walrus Assets</h1>
19
19
  </template>
20
20
  <template #actions>
21
- <TipConfigBadge :host="OPERATOR_RELAY_HOSTS[NETWORK]" />
21
+ <TipConfigBadge v-if="walrusNetwork" :host="OPERATOR_RELAY_HOSTS[walrusNetwork]" />
22
22
  <ColorModeControl :model-value="mode" @update:model-value="set" />
23
23
  </template>
24
24
  </AppHeader>
25
25
 
26
- <div class="app__content">
26
+ <main class="app__content">
27
27
  <WalrusView />
28
- </div>
28
+ </main>
29
29
 
30
30
  <AppFooter :docs-url="DOCS_URL" :dev-url="DEV_URL" />
31
31
  </div>
@@ -5,7 +5,7 @@ import type { OwnedBlob } from '@meddleware/walrus-client'
5
5
  import { MAX_SINGLE_RESERVATION_EPOCHS, formatCoinAmount } from '@meddleware/walrus-relay'
6
6
  import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
7
7
  import type { Executor } from '../wallet.js'
8
- import { NETWORK, walruscanBlobUrl } from '../config.js'
8
+ import { walrusNetwork, walruscanBlobUrl } from '../config.js'
9
9
  import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
10
10
  import { groupBlobs, expiryLabel, maxExtendableEpochs, type BlobGroup } from '../blob-groups.js'
11
11
  import {
@@ -13,7 +13,7 @@ import {
13
13
  loadPendingCertifies,
14
14
  clearPendingCertify,
15
15
  type PendingCertify,
16
- } from '../certify-resume.js'
16
+ } from '@meddleware/walrus-client/flow'
17
17
 
18
18
  const props = defineProps<{
19
19
  /** Connected wallet address whose owned blobs to list; `null` when no wallet is connected. */
@@ -24,6 +24,13 @@ const props = defineProps<{
24
24
  highlightBlobId?: string | null
25
25
  }>()
26
26
 
27
+ /** The active Walrus network; the view is only rendered on one (WalrusView gates it). */
28
+ function currentNetwork(): 'testnet' | 'mainnet' {
29
+ const net = walrusNetwork.value
30
+ if (!net) throw new Error('Walrus storage is not available on this network.')
31
+ return net
32
+ }
33
+
27
34
  // Shared, session-persistent cache (survives tab switches and inline re-mounts).
28
35
  const { blobs, currentEpoch, loading, error, load } = useOwnedBlobs()
29
36
 
@@ -77,11 +84,11 @@ function refresh(): Promise<void> {
77
84
 
78
85
  /** Reload the pending map from storage, dropping entries whose blob is already certified. */
79
86
  function refreshPending(): void {
80
- if (!props.address) {
87
+ if (!props.address || !walrusNetwork.value) {
81
88
  pending.value = {}
82
89
  return
83
90
  }
84
- const key = pendingCertifyKey(NETWORK, props.address)
91
+ const key = pendingCertifyKey(walrusNetwork.value, props.address)
85
92
  const map = loadPendingCertifies(window.localStorage, key)
86
93
  for (const blob of blobs.value) {
87
94
  if (blob.certified && blob.objectId in map) {
@@ -108,7 +115,7 @@ async function estimateExtend(blob: OwnedBlob, epochs: number): Promise<void> {
108
115
  const token = (extendReq[id] = (extendReq[id] ?? 0) + 1)
109
116
  try {
110
117
  const { createWalrusClient, estimateStorageCost } = await import('@meddleware/walrus-client')
111
- const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
118
+ const walrusClient = createWalrusClient({ network: currentNetwork(), wasmUrl: walrusWasmUrl })
112
119
  // Extend adds storage only (no one-time write cost), so price the `storageCost` component.
113
120
  const cost = await estimateStorageCost(walrusClient, blob.size, epochs)
114
121
  if (extendReq[id] === token) {
@@ -126,7 +133,7 @@ async function extendBlob(blob: OwnedBlob): Promise<void> {
126
133
  actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Building transaction…' }
127
134
  try {
128
135
  const { createWalrusClient, extendBlobLifetimeTransaction } = await import('@meddleware/walrus-client')
129
- const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
136
+ const walrusClient = createWalrusClient({ network: currentNetwork(), wasmUrl: walrusWasmUrl })
130
137
  const tx = await extendBlobLifetimeTransaction(walrusClient, blob.objectId, { epochs })
131
138
  const executor = await props.buildExecutor()
132
139
  actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Approve in wallet…' }
@@ -149,7 +156,7 @@ async function certifyBlob(blob: OwnedBlob): Promise<void> {
149
156
  actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Building transaction…' }
150
157
  try {
151
158
  const { createWalrusClient, certifyBlobTransaction } = await import('@meddleware/walrus-client')
152
- const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
159
+ const walrusClient = createWalrusClient({ network: currentNetwork(), wasmUrl: walrusWasmUrl })
153
160
  const tx = certifyBlobTransaction(walrusClient, {
154
161
  blobId: entry.blobId,
155
162
  blobObjectId: entry.blobObjectId,
@@ -160,7 +167,7 @@ async function certifyBlob(blob: OwnedBlob): Promise<void> {
160
167
  actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Approve in wallet…' }
161
168
  const { digest } = await executor.signAndExecute(tx)
162
169
  await executor.waitForTransaction(digest)
163
- clearPendingCertify(window.localStorage, pendingCertifyKey(NETWORK, props.address), blob.objectId)
170
+ clearPendingCertify(window.localStorage, pendingCertifyKey(currentNetwork(), props.address), blob.objectId)
164
171
  actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Certified ✓ (${digest.slice(0, 8)}…)` }
165
172
  await refresh()
166
173
  } catch (e) {
@@ -172,9 +179,9 @@ async function certifyBlob(blob: OwnedBlob): Promise<void> {
172
179
 
173
180
  // Load on first open and whenever the address changes; re-derive pending on list/address changes.
174
181
  onMounted(() => void load(props.address))
175
- watch(() => props.address, (addr) => void load(addr))
182
+ watch([() => props.address, walrusNetwork], ([addr]) => void load(addr))
176
183
  watch(blobs, () => refreshPending())
177
- watch(() => props.address, () => refreshPending())
184
+ watch([() => props.address, walrusNetwork], () => refreshPending())
178
185
 
179
186
  // Seed each group's extend amount with a sensible default (+10, clamped) without pricing it — the
180
187
  // estimate is fetched on the first user interaction.
@@ -236,7 +243,7 @@ watch(
236
243
  >
237
244
  <td>
238
245
  <CopyableAddress :address="g.blobId" label="Copy blob ID">
239
- <ExplorerLink :href="walruscanBlobUrl(NETWORK, g.blobId)" :value="g.blobId" :chars="[8, 6]" />
246
+ <ExplorerLink :href="walruscanBlobUrl(walrusNetwork ?? 'testnet', g.blobId)" :value="g.blobId" :chars="[8, 6]" />
240
247
  </CopyableAddress>
241
248
  <button
242
249
  v-if="g.copies.length > 1"
@@ -3,33 +3,30 @@
3
3
  // Rendered standalone by walrus-ui's App.vue and inline by the dashboard. Wallet state comes
4
4
  // from the shared @meddleware/wallet-adapter singleton (via ./wallet.js), so connecting here or
5
5
  // in any other inline tool view reflects everywhere.
6
- import { ref, watch } from 'vue'
6
+ import { computed, ref, watch } from 'vue'
7
7
  import {
8
8
  WalrusUpload,
9
9
  AccessGateCta,
10
10
  useAccessGate,
11
11
  } from '@meddleware/walrus-relay'
12
- import type { UploadResult, UploadProgress, ExistingCopy } from '@meddleware/walrus-relay'
13
12
  import { AppTabNav, CopyableAddress, ExplorerLink, UiNotice, UiTabPanel, UiToolIntro, suiExplorerUrl, safeHref, type AppTab } from '@meddleware/ui'
14
13
  // Lightweight URL import — just the wasm asset URL (does not pull the walrus client).
15
14
  import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
16
15
  import { WalletGuard } from '@meddleware/wallet-adapter'
17
16
  import { useWallet, getSuiClient } from '../wallet.js'
18
- import { fetchChallenge, buildAccessProof } from '@meddleware/nft-gate-client'
19
- import { NETWORK, relayHosts, accessGate, uploadRelayMaxTipMist, walruscanBlobUrl } from '../config.js'
20
- import { runBlobUpload } from '../upload-flow.js'
21
- import { getCertifyRetry } from '@meddleware/walrus-relay'
17
+ import { network, walrusNetwork, relayHosts, accessGate, uploadRelayMaxTipMist, walruscanBlobUrl } from '../config.js'
22
18
  import {
19
+ runBlobUpload,
20
+ createGatedAccess,
23
21
  consumeStorageKey,
24
- isRedeemedConflict,
25
- resolveGatedAuthToken,
26
- } from '../access-resume.js'
27
- import {
28
22
  pendingCertifyKey,
29
23
  savePendingCertify,
30
24
  clearPendingCertify,
31
25
  loadPendingCertifies,
32
- } from '../certify-resume.js'
26
+ type BlobUploadResult as UploadResult,
27
+ type ExistingCopy,
28
+ type UploadProgress,
29
+ } from '@meddleware/walrus-client/flow'
33
30
  import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
34
31
  import MyBlobs from './MyBlobs.vue'
35
32
 
@@ -41,16 +38,29 @@ const activeTab = ref<string>('upload')
41
38
 
42
39
  const { account, signPersonalMessage, buildExecutor } = useWallet()
43
40
 
44
- const gate = accessGate(NETWORK)
45
- const gateState = useAccessGate({ gate, getClient: () => getSuiClient() })
41
+ // One access-gate state per Walrus network (the gate is a per-network setting); the active
42
+ // network's is used. Each is inert until an ownership check runs.
43
+ const gateStates = {
44
+ testnet: useAccessGate({ gate: accessGate('testnet'), getClient: () => getSuiClient() }),
45
+ mainnet: useAccessGate({ gate: accessGate('mainnet'), getClient: () => getSuiClient() }),
46
+ }
47
+ /** The active network's gate state (testnet's while the view is hidden on a non-Walrus network). */
48
+ const gateState = computed(() => gateStates[walrusNetwork.value ?? 'testnet'])
46
49
  const purchasing = ref(false)
47
50
  const result = ref<UploadResult | null>(null)
48
51
 
49
- // Check gate ownership whenever the connected account changes (handles connect, reconnect,
50
- // and disconnect without needing a manual trigger from the connect button).
52
+ // Check gate ownership whenever the connected account or the network changes. A switched or
53
+ // disconnected wallet never inherits the previous wallet's access state: `reset()` also discards
54
+ // any in-flight check.
51
55
  watch(
52
- () => account.value?.address ?? null,
53
- (addr) => { if (addr && gate) void gateState.checkOwnership(addr) },
56
+ [() => account.value?.address ?? null, walrusNetwork],
57
+ ([addr], previous) => {
58
+ const prevAddr = previous?.[0] ?? null
59
+ if (addr !== prevAddr) for (const s of Object.values(gateStates)) s.reset()
60
+ else gateState.value.reset()
61
+ result.value = null
62
+ if (addr && walrusNetwork.value && gateState.value.gate) void gateState.value.checkOwnership(addr)
63
+ },
54
64
  { immediate: true },
55
65
  )
56
66
 
@@ -59,106 +69,74 @@ async function onPurchase(): Promise<void> {
59
69
  purchasing.value = true
60
70
  try {
61
71
  const executor = await buildExecutor()
62
- await gateState.purchase(executor, account.value.address)
72
+ await gateState.value.purchase(executor, account.value.address)
63
73
  } finally {
64
74
  purchasing.value = false
65
75
  }
66
76
  }
67
77
 
68
- // Wire the shared WalrusUpload widget to the extracted upload orchestration + the wallet. The
69
- // register/upload/certify sequence lives in src/upload-flow.ts (unit-tested); this closure gathers
70
- // the wallet-bound inputs and manages the single-use consume resume layer:
71
- // Single-use consume: the relay treats the permanent on-chain `consumeDigest` as the one-time
72
- // redemption token, so a use is only spent when an upload succeeds. The digest is persisted and
73
- // reused across retries/reload (re-signing a fresh challenge is free); cleared on success.
74
- // The Walrus registration is NOT resumed — with an upload relay the tip + nonce live in the register
75
- // transaction and the relay requires it to be recent, so every attempt registers fresh (see
76
- // upload-flow.ts). Reusing a prior registration is what produced "the received transaction is too old".
78
+ // Wire the shared WalrusUpload widget to walrus-client's upload orchestrator and the wallet. The
79
+ // orchestrator registers fresh on every attempt (the relay rejects an old register transaction),
80
+ // and, for a gated relay, `createGatedAccess` keeps the single-use consume resumable: its digest is
81
+ // persisted before the upload, reused after an interruption, cleared once the upload lands, and
82
+ // replaced only after the gateway reports it redeemed.
77
83
  async function performUpload(
78
84
  bytes: Uint8Array,
79
85
  opts: {
80
86
  relayHost: string
81
87
  epochs: number
82
88
  force?: boolean
89
+ deletable?: boolean
83
90
  onStatus: (s: string | UploadProgress) => void
84
91
  },
85
92
  ): Promise<UploadResult> {
86
93
  if (!account.value) throw new Error('Connect your wallet first.')
94
+ const net = walrusNetwork.value
95
+ if (!net) throw new Error(`Walrus storage is not available on ${network.value}.`)
87
96
  const executor = await buildExecutor()
88
97
  const address = account.value.address
89
98
  const storage = window.localStorage
99
+ const state = gateState.value
100
+ const gate = state.gate
101
+ const nftId = state.nftId.value
90
102
 
91
- const gated = !!(gate && gateState.hasAccess.value === true && gateState.nftId.value)
92
- const consumeKey = gate ? consumeStorageKey(NETWORK, gate.gateId, address) : null
93
-
94
- // Gated uploads spend one NFT use on-chain before the core flow — surface it as the leading
95
- // "Access" step so the stepper reflects the extra wallet approval (a reused consume is instant).
96
- if (gated) opts.onStatus({ step: 'access', detail: 'Confirming access…' })
97
-
98
- // Resolve this attempt's relay token (gated only); reuses a stored consume, fresh challenge each time.
99
- const token = (forceFresh: boolean): Promise<string | undefined> =>
100
- !gated
101
- ? Promise.resolve(undefined)
102
- : resolveGatedAuthToken({
103
+ const access =
104
+ gate && state.hasAccess.value === true && nftId
105
+ ? createGatedAccess({
103
106
  storage,
104
- key: consumeKey as string,
107
+ key: consumeStorageKey(net, gate.gateId, address),
105
108
  relayHost: opts.relayHost,
106
109
  address,
107
- nftId: gateState.nftId.value as string,
108
- fetchChallenge,
109
- buildConsume: (id, nonce) => gateState.buildConsume(id, nonce),
110
+ nftId,
111
+ singleUse: state.usesRemaining.value !== null,
112
+ buildConsume: (id, nonce) => state.buildConsume(id, nonce),
110
113
  signAndExecute: (tx) => executor.signAndExecute(tx),
111
114
  waitForTransaction: (digest) => executor.waitForTransaction(digest),
112
- buildAccessProof,
113
115
  sign: signPersonalMessage,
114
- forceFresh,
116
+ onStatus: opts.onStatus,
115
117
  })
118
+ : undefined
116
119
 
117
- const runOnce = async (authToken: string | undefined): Promise<UploadResult> => {
118
- try {
119
- const r = await runBlobUpload({
120
- bytes,
121
- network: NETWORK,
122
- relayHost: opts.relayHost,
123
- address,
124
- wasmUrl: walrusWasmUrl,
125
- maxTipMist: uploadRelayMaxTipMist(),
126
- epochs: opts.epochs,
127
- force: opts.force,
128
- findExistingCopy,
129
- executor,
130
- suiClient: getSuiClient(),
131
- authToken,
132
- onStatus: opts.onStatus,
133
- // Persist the certificate the moment the upload lands, and drop it once certified — so a
134
- // dismissed certify can be finished from My Blobs after a tab switch or reload.
135
- onUploaded: (info) =>
136
- savePendingCertify(storage, pendingCertifyKey(NETWORK, address), info),
137
- onCertified: (blobObjectId) =>
138
- clearPendingCertify(storage, pendingCertifyKey(NETWORK, address), blobObjectId),
139
- })
140
- // Success → clear the consume layer (the use is now genuinely spent for an upload).
141
- if (consumeKey) storage.removeItem(consumeKey)
142
- return r
143
- } catch (e) {
144
- // A certify-only failure means the upload already landed (relay access was used), so clear the
145
- // consume too — the certify retry is a plain Sui tx and must not trigger a fresh NFT consume.
146
- if (getCertifyRetry(e) && consumeKey) storage.removeItem(consumeKey)
147
- throw e
148
- }
149
- }
150
-
151
- try {
152
- return await runOnce(await token(false))
153
- } catch (e) {
154
- if (gated && isRedeemedConflict(e)) {
155
- // Stored consume already redeemed (a prior upload actually landed): clear it, spend a fresh
156
- // use, and retry.
157
- storage.removeItem(consumeKey as string)
158
- return await runOnce(await token(true))
159
- }
160
- throw e
161
- }
120
+ return runBlobUpload({
121
+ bytes,
122
+ network: net,
123
+ relayHost: opts.relayHost,
124
+ address,
125
+ wasmUrl: walrusWasmUrl,
126
+ maxTipMist: uploadRelayMaxTipMist(),
127
+ epochs: opts.epochs,
128
+ deletable: opts.deletable,
129
+ force: opts.force,
130
+ findExistingCopy,
131
+ executor,
132
+ suiClient: getSuiClient(),
133
+ access,
134
+ onStatus: opts.onStatus,
135
+ // Persist the certificate the moment the upload lands, and drop it once certified — so a
136
+ // dismissed certify can be finished from My Blobs after a tab switch or reload.
137
+ onUploaded: (info) => savePendingCertify(storage, pendingCertifyKey(net, address), info),
138
+ onCertified: (blobObjectId) => clearPendingCertify(storage, pendingCertifyKey(net, address), blobObjectId),
139
+ })
162
140
  }
163
141
 
164
142
  // Precheck (before paying to register): does the wallet already own this exact blob? Returns a
@@ -166,10 +144,11 @@ async function performUpload(
166
144
  // (offer Certify). Best-effort: any failure returns null so the upload simply proceeds.
167
145
  async function findExistingCopy(blobId: string): Promise<ExistingCopy | null> {
168
146
  const address = account.value?.address
169
- if (!address) return null
147
+ const net = walrusNetwork.value
148
+ if (!address || !net) return null
170
149
  try {
171
150
  const { createWalrusClient, fetchOwnedWalrusBlobs } = await import('@meddleware/walrus-client')
172
- const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
151
+ const walrusClient = createWalrusClient({ network: net, wasmUrl: walrusWasmUrl })
173
152
  const [sys, owned] = await Promise.all([
174
153
  walrusClient.walrus.systemState(),
175
154
  fetchOwnedWalrusBlobs(getSuiClient(), walrusClient, address),
@@ -180,7 +159,7 @@ async function findExistingCopy(blobId: string): Promise<ExistingCopy | null> {
180
159
  if (certified) {
181
160
  return { kind: 'certified', blobId, objectId: certified.objectId, endEpoch: certified.endEpoch }
182
161
  }
183
- const store = loadPendingCertifies(window.localStorage, pendingCertifyKey(NETWORK, address))
162
+ const store = loadPendingCertifies(window.localStorage, pendingCertifyKey(net, address))
184
163
  const pending = copies.find((b) => !b.certified && b.objectId in store)
185
164
  if (pending) {
186
165
  return { kind: 'pending', blobId, objectId: pending.objectId, endEpoch: pending.endEpoch }
@@ -194,9 +173,11 @@ async function findExistingCopy(blobId: string): Promise<ExistingCopy | null> {
194
173
  // Injected into the upload widget so it can price a chosen reservation duration (storage is WAL,
195
174
  // billed per size × epochs). Returns total cost in FROST, or null on error.
196
175
  async function estimateUploadStorageCost(sizeBytes: number, epochs: number): Promise<bigint | null> {
176
+ const net = walrusNetwork.value
177
+ if (!net) return null
197
178
  try {
198
179
  const { createWalrusClient, estimateStorageCost } = await import('@meddleware/walrus-client')
199
- const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
180
+ const walrusClient = createWalrusClient({ network: net, wasmUrl: walrusWasmUrl })
200
181
  const cost = await estimateStorageCost(walrusClient, sizeBytes, epochs)
201
182
  return cost.totalCost
202
183
  } catch {
@@ -224,18 +205,23 @@ function onUploaded(r: UploadResult): void {
224
205
  function onSettled(): void {
225
206
  if (account.value) {
226
207
  void ownedBlobs.load(account.value.address, { force: true })
227
- void gateState.checkOwnership(account.value.address)
208
+ if (gateState.value.gate) void gateState.value.checkOwnership(account.value.address)
228
209
  }
229
210
  }
230
211
  </script>
231
212
 
232
213
  <template>
233
- <UiToolIntro>Upload and manage blobs on Walrus decentralised storage ({{ NETWORK }}).</UiToolIntro>
214
+ <UiToolIntro>Upload and manage blobs on Walrus decentralised storage ({{ network }}).</UiToolIntro>
215
+
216
+ <UiNotice v-if="!walrusNetwork" type="info">Walrus storage is not available on {{ network }}. Switch to testnet or mainnet.</UiNotice>
217
+ <template v-else>
234
218
 
235
219
  <AppTabNav v-model="activeTab" :tabs="TABS" id-prefix="walrus" aria-label="Feature tabs" class="walrus-tabs" />
236
220
 
237
- <WalletGuard message="Connect a Sui wallet to upload and manage your blobs.">
238
- <UiTabPanel id-prefix="walrus" :tab="activeTab">
221
+ <!-- The tab list and its panel always render (the selected tab controls a live panel); the wallet
222
+ prompt replaces only the panel's content until a wallet is connected. -->
223
+ <UiTabPanel id-prefix="walrus" :tab="activeTab">
224
+ <WalletGuard message="Connect a Sui wallet to upload and manage your blobs.">
239
225
  <template v-if="activeTab === 'upload'">
240
226
  <!-- Gated + no access: replace the form with the purchase CTA so the user is guided to buy
241
227
  first, rather than facing a disabled form. -->
@@ -244,7 +230,7 @@ function onSettled(): void {
244
230
  :gate-configured="gateState.gateConfigured"
245
231
  :has-access="gateState.hasAccess.value"
246
232
  :busy="purchasing"
247
- :price-mist="gate?.priceMist ?? null"
233
+ :price-mist="gateState.gate?.priceMist ?? null"
248
234
  @purchase="onPurchase"
249
235
  />
250
236
  </div>
@@ -271,7 +257,7 @@ function onSettled(): void {
271
257
  </UiNotice>
272
258
 
273
259
  <WalrusUpload
274
- :hosts="relayHosts(NETWORK)"
260
+ :hosts="relayHosts(walrusNetwork)"
275
261
  :connected="!!account"
276
262
  :access="{ gateConfigured: gateState.gateConfigured, hasAccess: gateState.hasAccess }"
277
263
  :perform-upload="performUpload"
@@ -287,7 +273,7 @@ function onSettled(): void {
287
273
  <strong>Blob ID:</strong>
288
274
  <CopyableAddress :address="result.blobId" label="Copy blob ID">
289
275
  <ExplorerLink
290
- :href="walruscanBlobUrl(NETWORK, result.blobId)"
276
+ :href="walruscanBlobUrl(walrusNetwork, result.blobId)"
291
277
  :value="result.blobId"
292
278
  :chars="[8, 6]"
293
279
  />
@@ -301,7 +287,7 @@ function onSettled(): void {
301
287
  <strong>Certify tx:</strong>
302
288
  <CopyableAddress :address="result.digest" label="Copy transaction digest">
303
289
  <ExplorerLink
304
- :href="suiExplorerUrl('txblock', result.digest, NETWORK)"
290
+ :href="suiExplorerUrl('txblock', result.digest, walrusNetwork)"
305
291
  :value="result.digest"
306
292
  :chars="[8, 6]"
307
293
  />
@@ -317,8 +303,9 @@ function onSettled(): void {
317
303
  :build-executor="() => buildExecutor()"
318
304
  :highlight-blob-id="highlightBlobId"
319
305
  />
320
- </UiTabPanel>
321
- </WalletGuard>
306
+ </WalletGuard>
307
+ </UiTabPanel>
308
+ </template>
322
309
  </template>
323
310
 
324
311
  <style scoped>
@@ -3,37 +3,41 @@
3
3
  // State lives at module scope (not inside the composable factory) so it is shared across every
4
4
  // `MyBlobs` mount in the session: switching the Upload/My-Blobs tabs — or navigating the whole
5
5
  // tool view in and out inline in the dashboard — no longer loses the list or forces a refetch.
6
- // `load()` is a no-op when the list is already loaded for the given address, unless `force` is set
6
+ // `load()` is a no-op when the list is already loaded for the given address on the active network,
7
+ // unless `force` is set
7
8
  // (used after an upload/extend, which can create or mutate on-chain blobs).
8
9
  import { ref } from 'vue'
9
10
  import type { OwnedBlob } from '@meddleware/walrus-client'
10
11
  // Lightweight URL import — just the wasm asset URL (does not pull the walrus client eagerly).
11
12
  import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
12
13
  import { getSuiClient } from '../wallet.js'
13
- import { NETWORK } from '../config.js'
14
+ import { walrusNetwork } from '../config.js'
14
15
 
15
16
  const blobs = ref<OwnedBlob[]>([])
16
17
  const currentEpoch = ref(0)
17
18
  const loading = ref(false)
18
19
  const error = ref<string | null>(null)
19
- /** Address the current `blobs` were fetched for; `null` until a successful load. */
20
+ /** `<network>|<address>` the current `blobs` were fetched for; `null` until a successful load. */
20
21
  const loadedFor = ref<string | null>(null)
21
22
 
22
23
  async function load(address: string | null, opts: { force?: boolean } = {}): Promise<void> {
23
- if (!address) {
24
+ const net = walrusNetwork.value
25
+ if (!address || !net) {
24
26
  blobs.value = []
25
27
  loadedFor.value = null
26
28
  return
27
29
  }
28
30
  // Skip refetch when we already have this address's list and the last load succeeded.
29
- if (!opts.force && loadedFor.value === address && error.value === null) return
31
+ const key = `${net}|${address}`
32
+ if (!opts.force && loadedFor.value === key && error.value === null) return
33
+ if (loadedFor.value !== key) blobs.value = [] // never show another network's or wallet's list
30
34
 
31
35
  loading.value = true
32
36
  error.value = null
33
37
  try {
34
38
  const { createWalrusClient, fetchOwnedWalrusBlobs } = await import('@meddleware/walrus-client')
35
39
  const suiClient = getSuiClient()
36
- const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
40
+ const walrusClient = createWalrusClient({ network: net, wasmUrl: walrusWasmUrl })
37
41
  // Blob `endEpoch` is a WALRUS epoch, so compare against the Walrus committee epoch — NOT the
38
42
  // Sui system-state epoch (they are different clocks; the Sui epoch, ~1218 vs ~570, made every
39
43
  // blob read as "expired").
@@ -43,7 +47,7 @@ async function load(address: string | null, opts: { force?: boolean } = {}): Pro
43
47
  ])
44
48
  currentEpoch.value = Number(sys.committee.epoch)
45
49
  blobs.value = fetched.sort((a: OwnedBlob, b: OwnedBlob) => a.endEpoch - b.endEpoch)
46
- loadedFor.value = address
50
+ loadedFor.value = key
47
51
  } catch (e) {
48
52
  error.value = e instanceof Error ? e.message : String(e)
49
53
  } finally {
package/src/config.ts CHANGED
@@ -1,12 +1,12 @@
1
- // Build-time configuration (Vite inlines VITE_*). The relay hostnames default to
2
- // the public Mysten relay; set VITE_WALRUS_RELAY_* to your operator relay to collect
3
- // the tip. Access-gate config is optional (unset ⇒ ungated).
1
+ // Configuration. The network is wallet-adapter's shared runtime selector (the standalone build
2
+ // selects VITE_NETWORK in main.ts; embedded, the host's selector rules). Operator settings are
3
+ // build-time VITE_* per network: relay hostnames default to the public Mysten relay (set
4
+ // VITE_WALRUS_RELAY_* to your operator relay to collect the tip); the access gate is optional
5
+ // (unset ⇒ ungated), and its package + PlatformConfig come from the published deployment.
6
+ import { computed } from 'vue'
4
7
  import type { WalrusNetwork, RelayGateConfig } from '@meddleware/walrus-relay'
5
- import {
6
- ACCESS_GATE_PACKAGE_ID,
7
- ACCESS_GATE_PLATFORM_CONFIG_ID,
8
- accessGateNftType,
9
- } from '@meddleware/walrus-relay'
8
+ import { relayGateConfig } from '@meddleware/walrus-relay'
9
+ import { useNetwork } from '@meddleware/wallet-adapter'
10
10
 
11
11
  /** Build-time env bag. Injectable on the env-reading helpers below purely so unit tests can exercise
12
12
  * each branch without depending on Vite's (frozen) `import.meta.env` inlining. */
@@ -14,8 +14,13 @@ export type EnvSource = Record<string, string | undefined>
14
14
 
15
15
  const env: EnvSource = (import.meta as unknown as { env?: EnvSource }).env ?? {}
16
16
 
17
- /** Active Walrus network, from `VITE_NETWORK` (default `testnet`). */
18
- export const NETWORK: WalrusNetwork = (env.VITE_NETWORK as WalrusNetwork) || 'testnet'
17
+ /** The active network (read-only ref). */
18
+ export const network = useNetwork().network
19
+
20
+ /** The active network as a Walrus network, or null where Walrus has none (e.g. localnet). */
21
+ export const walrusNetwork = computed<WalrusNetwork | null>(() =>
22
+ network.value === 'testnet' || network.value === 'mainnet' ? network.value : null,
23
+ )
19
24
 
20
25
  /** Public Mysten relays — the fallback when no operator relay is configured. */
21
26
  export const PUBLIC_WALRUS_RELAY_HOSTS: Record<WalrusNetwork, string> = {
@@ -42,8 +47,11 @@ export function walruscanBlobUrl(network: WalrusNetwork, blobId: string): string
42
47
  return `https://walruscan.com/${network}/blob/${blobId}`
43
48
  }
44
49
 
45
- /** Default relay tip ceiling (MIST) when `VITE_UPLOAD_RELAY_MAX_TIP_MIST` is unset (0.5 SUI). */
46
- export const DEFAULT_UPLOAD_RELAY_MAX_TIP_MIST = 500_000_000
50
+ /**
51
+ * Default relay tip ceiling (MIST) when `VITE_UPLOAD_RELAY_MAX_TIP_MIST` is unset: 0.05 SUI, the
52
+ * workspace-wide ceiling (~8× the operator relay's worst-case tip at the 100 MiB edge cap).
53
+ */
54
+ export const DEFAULT_UPLOAD_RELAY_MAX_TIP_MIST = 50_000_000
47
55
 
48
56
  /**
49
57
  * Cap on the relay tip payment in MIST, from `VITE_UPLOAD_RELAY_MAX_TIP_MIST`. This is a ceiling to
@@ -56,33 +64,23 @@ export function uploadRelayMaxTipMist(envSource: EnvSource = env): number {
56
64
  }
57
65
 
58
66
  /**
59
- * gRPC-web endpoint used to build + execute the register/certify transactions and read owned
60
- * blobs. The Sui SDK's JSON-RPC client is deprecated, so this must be a gRPC-web-capable endpoint
61
- * (the Mysten public fullnodes serve gRPC-web at :443 via the browser Fetch transport).
62
- */
63
- export const RPC_URLS: Record<WalrusNetwork, string> = {
64
- testnet: env.VITE_RPC_TESTNET || 'https://fullnode.testnet.sui.io:443',
65
- mainnet: env.VITE_RPC_MAINNET || 'https://fullnode.mainnet.sui.io:443',
66
- }
67
-
68
- /**
69
- * Parse the optional NFT access-gate config for a network from env.
70
- * packageId and platformConfigId are hardcoded in the library — operators only
71
- * need to supply their Gate object ID, soulbound flag, and purchase price.
67
+ * The optional NFT access gate for a network: the operator supplies only the Gate id, soulbound
68
+ * flag and purchase price (`VITE_ACCESS_GATE_*_{NET}`); `relayGateConfig` fixes the package and
69
+ * `PlatformConfig` to the published deployment (commission enforcement). `null` — an ungated
70
+ * relay — when no gate id is set, or when no access_gate deployment is recorded for the network.
72
71
  */
73
72
  export function accessGate(network: WalrusNetwork, envSource: EnvSource = env): RelayGateConfig | null {
74
73
  const NET = network.toUpperCase()
75
- const packageId = ACCESS_GATE_PACKAGE_ID[network]
76
- const platformConfigId = ACCESS_GATE_PLATFORM_CONFIG_ID[network]
77
74
  const gateId = envSource[`VITE_ACCESS_GATE_ID_${NET}`]
78
- if (!packageId || !platformConfigId || !gateId) return null
79
- const soulbound = envSource[`VITE_ACCESS_GATE_SOULBOUND_${NET}`] === 'true'
80
- return {
81
- packageId,
82
- gateId,
83
- platformConfigId,
84
- nftType: accessGateNftType(network, soulbound),
85
- soulbound,
86
- priceMist: BigInt(envSource[`VITE_ACCESS_GATE_PRICE_MIST_${NET}`] || '0'),
75
+ if (!gateId) return null
76
+ try {
77
+ return relayGateConfig(network, {
78
+ gateId,
79
+ soulbound: envSource[`VITE_ACCESS_GATE_SOULBOUND_${NET}`] === 'true',
80
+ priceMist: BigInt(envSource[`VITE_ACCESS_GATE_PRICE_MIST_${NET}`] || '0'),
81
+ })
82
+ } catch (e) {
83
+ console.warn(`[walrus-ui] access gate for ${network} disabled: ${e instanceof Error ? e.message : String(e)}`)
84
+ return null
87
85
  }
88
86
  }
package/src/main.ts CHANGED
@@ -3,7 +3,14 @@ import '@meddleware/design-tokens/seasons.css'
3
3
  import '@meddleware/ui/base.css'
4
4
  import { createApp } from 'vue'
5
5
  import { useSeason } from '@meddleware/ui'
6
+ import { useNetwork } from '@meddleware/wallet-adapter'
6
7
  import App from './App.vue'
7
8
 
9
+ // The standalone build targets one network; select it in the shared selector so the client, the
10
+ // relay, the gate and the stored resume keys all agree. (Embedded, the host's selector rules.)
11
+ const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env ?? {}
12
+ const built = env.VITE_NETWORK || 'testnet'
13
+ if (built === 'testnet' || built === 'mainnet') useNetwork().setNetwork(built)
14
+
8
15
  useSeason()
9
16
  createApp(App).mount('#app')
@@ -1,114 +0,0 @@
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
- // 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.
29
-
30
- /**
31
- * True if `err` is the gateway's "this consume was already redeemed" rejection (HTTP 409 with
32
- * `code: 'redeemed'`). Distinguished from transient failures so we only re-consume (spend a new
33
- * use) when the stored digest is genuinely spent — never on a network blip.
34
- */
35
- export function isRedeemedConflict(err: unknown): boolean {
36
- const e = err as { status?: number; error?: { code?: string } } | null
37
- return !!e && e.status === 409 && e.error?.code === 'redeemed'
38
- }
39
-
40
- /** A challenge carrying at least a `nonce` (structural; the full object is passed through). */
41
- export interface ChallengeLike {
42
- nonce: string
43
- }
44
-
45
- /**
46
- * Injected dependencies for {@link resolveGatedAuthToken} (all wallet/relay/chain calls).
47
- * Generic over the challenge (`C`), transaction (`Tx`), and signer (`S`) types so the wallet's
48
- * concrete `PersonalMessageSigner` flows through to `buildAccessProof` without widening.
49
- */
50
- export interface ResolveTokenDeps<C extends ChallengeLike, Tx, S> {
51
- storage: StorageLike
52
- key: string
53
- relayHost: string
54
- address: string
55
- nftId: string
56
- /** Fetch a fresh challenge nonce from the gateway. */
57
- fetchChallenge: (relayHost: string) => Promise<C>
58
- /** Build the on-chain `access_gate::consume` PTB for `nftId` + `nonce`. */
59
- buildConsume: (nftId: string, nonce: string) => Tx
60
- /** Sign + execute a PTB; resolves with the transaction digest. */
61
- signAndExecute: (tx: Tx) => Promise<{ digest?: string }>
62
- /** Wait for a transaction to finalise (best-effort; errors are swallowed by the caller). */
63
- waitForTransaction: (digest: string) => Promise<unknown>
64
- /** Build the base64 relay access-proof token. */
65
- buildAccessProof: (args: {
66
- address: string
67
- challenge: C
68
- sign: S
69
- consumeDigest?: string
70
- }) => Promise<string>
71
- /** Personal-message signer (free — no gas, no use). */
72
- sign: S
73
- /** Ignore any stored digest and consume a fresh use (used after a `redeemed` conflict). */
74
- forceFresh?: boolean
75
- }
76
-
77
- /**
78
- * Resolve the relay auth token for a gated upload, reusing a persisted consume when present.
79
- *
80
- * - A stored (unspent) digest ⇒ reuse it: fetch a fresh challenge, sign (free), no new consume.
81
- * - Otherwise ⇒ consume one use on-chain and persist the digest BEFORE the upload, so an
82
- * interruption after this point resumes rather than re-consuming.
83
- *
84
- * The caller must clear the stored key on a successful upload and may retry with `forceFresh`
85
- * after {@link isRedeemedConflict}.
86
- */
87
- export async function resolveGatedAuthToken<C extends ChallengeLike, Tx, S>(
88
- deps: ResolveTokenDeps<C, Tx, S>,
89
- ): Promise<string> {
90
- const stored = deps.forceFresh ? null : deps.storage.getItem(deps.key)
91
- const challenge = await deps.fetchChallenge(deps.relayHost)
92
-
93
- let consumeDigest: string | undefined
94
- if (stored) {
95
- // Resume: reuse the already-consumed use; the fresh challenge is only for signature freshness.
96
- consumeDigest = stored
97
- } else {
98
- const consumeTx = deps.buildConsume(deps.nftId, challenge.nonce)
99
- const res = await deps.signAndExecute(consumeTx)
100
- consumeDigest = res.digest
101
- // Persist BEFORE the upload so a crash/reload between consume and upload success can resume.
102
- if (consumeDigest) {
103
- deps.storage.setItem(deps.key, consumeDigest)
104
- await deps.waitForTransaction(consumeDigest).catch(() => {})
105
- }
106
- }
107
-
108
- return deps.buildAccessProof({
109
- address: deps.address,
110
- challenge,
111
- sign: deps.sign,
112
- consumeDigest,
113
- })
114
- }
@@ -1,66 +0,0 @@
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
- }
@@ -1,179 +0,0 @@
1
- // The multi-step Walrus blob upload orchestration (encode → register → upload → certify → resolve),
2
- // extracted from App.vue so it can be unit-tested and reused by the e2e harness.
3
- //
4
- // The `@mysten/walrus` wasm client must NOT be pulled into the eager module graph (see CLAUDE.md),
5
- // so this module never imports @meddleware/walrus-client at top level — it loads it lazily through
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.
14
- import type { UploadResult, UploadProgress, ExistingCopy } from '@meddleware/walrus-relay'
15
- import { attachCertifyRetry, attachDuplicateExisting } from '@meddleware/walrus-relay'
16
-
17
- /** Minimal transaction executor — the structural subset App.vue's wallet executor already provides. */
18
- export interface UploadExecutor {
19
- signAndExecute(tx: unknown): Promise<{ digest: string }>
20
- waitForTransaction(digest: string): Promise<unknown>
21
- }
22
-
23
- /** The slice of `@meddleware/walrus-client` this flow needs. */
24
- export interface WalrusClientModule {
25
- createWalrusClient: (opts: Record<string, unknown>) => unknown
26
- createBlobUploadFlow: (client: unknown, bytes: Uint8Array) => BlobUploadFlow
27
- walrusBlobUrl: (network: string, blobId: string) => string
28
- }
29
-
30
- interface BuiltTx {
31
- setSenderIfNotSet(address: string): void
32
- build(opts: { client: unknown }): Promise<unknown>
33
- }
34
-
35
- export interface BlobUploadFlow {
36
- // Encoding is deterministic from the content and costs no gas; it also mints the per-attempt relay
37
- // `nonce` committed by the register tip, so it must precede register/upload on this flow instance.
38
- // Returns the content-derived `blobId`, used to check for an existing owned copy before registering.
39
- encode(): Promise<{ blobId: string }>
40
- register(opts: { owner: string; epochs: number; deletable: boolean }): BuiltTx
41
- // `digest` = the register transaction digest produced by the register tx executed this attempt.
42
- // Returns the SDK "uploaded" step, including the on-chain `blobObjectId` and the base64
43
- // availability `certificate` needed to certify later (persisted so certify can resume).
44
- upload(opts: { digest: string; deletable?: boolean }): Promise<{
45
- blobId: string
46
- blobObjectId: string
47
- certificate: string
48
- }>
49
- certify(): BuiltTx
50
- getBlob(): Promise<{ blobId: string }>
51
- }
52
-
53
- export interface RunBlobUploadDeps {
54
- bytes: Uint8Array
55
- network: string
56
- relayHost: string
57
- /** Owner/sender address (already-connected wallet account). */
58
- address: string
59
- /** Wasm bundle URL for the Walrus client. */
60
- wasmUrl: string
61
- /** Cap on the relay tip payment in MIST. */
62
- maxTipMist: number
63
- /** Blob storage reservation length in epochs. */
64
- epochs: number
65
- /**
66
- * When true, skip the existing-copy precheck and register a fresh copy unconditionally (the user
67
- * chose "Upload a new copy" after being warned).
68
- */
69
- force?: boolean
70
- /**
71
- * Precheck for an existing owned copy of this content (by `blobId`, computed from `encode()`).
72
- * When it returns a match and `force` is not set, the upload aborts BEFORE registering (no
73
- * reservation, no tip) and throws an error carrying the match so the UI can offer Extend/Certify.
74
- */
75
- findExistingCopy?: (blobId: string) => Promise<ExistingCopy | null>
76
- executor: UploadExecutor
77
- /** A Sui client used to `build()` the register/certify transactions. */
78
- suiClient: unknown
79
- /**
80
- * Bearer proof token for an NFT-gated relay. May be a provider resolved per request so a retried
81
- * upload presents a fresh challenge signature (see `@meddleware/walrus-client`).
82
- */
83
- authToken?: string | (() => string | undefined)
84
- /** Structured step progress; drives the stepped indicator in the WalrusUpload widget. */
85
- onStatus: (p: UploadProgress) => void
86
- /** Lazy loader for the Walrus client module (keeps wasm out of the eager bundle). */
87
- loadWalrusClient?: () => Promise<WalrusClientModule>
88
- /**
89
- * Called once the upload has landed (blob registered + stored + paid) but BEFORE certify, with the
90
- * data needed to certify later without re-uploading. Persist it so a dismissed certify can be
91
- * resumed from My Blobs after a tab switch or reload. Cleared via {@link RunBlobUploadDeps.onCertified}.
92
- */
93
- onUploaded?: (info: { blobId: string; blobObjectId: string; certificate: string; deletable: boolean }) => void
94
- /** Called with the `blobObjectId` once certify succeeds, so the caller can drop the persisted entry. */
95
- onCertified?: (blobObjectId: string) => void
96
- }
97
-
98
- /**
99
- * Register → upload → certify a blob and resolve its id + public URL. Every attempt encodes
100
- * (client-side, no gas) and registers fresh: the relay tip + nonce the relay verifies live in the
101
- * register transaction and must be recent, so a registration is never reused across attempts. Two
102
- * wallet approvals (register and certify) are requested via `executor`; `onStatus` narrates.
103
- */
104
- export async function runBlobUpload(deps: RunBlobUploadDeps): Promise<UploadResult> {
105
- // The real module's flow types are richer than the narrow structural subset we use here, so the
106
- // default loader casts through `unknown`. Tests inject an exact-shape fake instead.
107
- const load =
108
- deps.loadWalrusClient ??
109
- (async () => (await import('@meddleware/walrus-client')) as unknown as WalrusClientModule)
110
- const { createWalrusClient, createBlobUploadFlow, walrusBlobUrl } = await load()
111
-
112
- const client = createWalrusClient({
113
- network: deps.network,
114
- wasmUrl: deps.wasmUrl,
115
- uploadRelayHost: deps.relayHost,
116
- uploadRelayAuthToken: deps.authToken,
117
- uploadRelayMaxTipMist: deps.maxTipMist,
118
- })
119
- const flow = createBlobUploadFlow(client, deps.bytes)
120
-
121
- deps.onStatus({ step: 'encode', detail: 'Encoding…' })
122
- const { blobId } = await flow.encode()
123
-
124
- // Before paying to register, check whether the wallet already owns this exact blob. If so, abort
125
- // and surface the match so the UI can offer Extend (certified) / Certify (pending) instead of
126
- // creating a wasteful duplicate. `force` (chosen "Upload a new copy") bypasses this.
127
- if (!deps.force && deps.findExistingCopy) {
128
- const existing = await deps.findExistingCopy(blobId)
129
- if (existing) {
130
- const err = new Error('Blob already stored on-chain')
131
- attachDuplicateExisting(err, existing)
132
- throw err
133
- }
134
- }
135
-
136
- deps.onStatus({ step: 'register', detail: 'Registering blob (approve in wallet)…' })
137
- const regTx = flow.register({ owner: deps.address, epochs: deps.epochs, deletable: false })
138
- regTx.setSenderIfNotSet(deps.address)
139
- await regTx.build({ client: deps.suiClient })
140
- const reg = await deps.executor.signAndExecute(regTx)
141
- await deps.executor.waitForTransaction(reg.digest)
142
-
143
- deps.onStatus({ step: 'upload', detail: 'Uploading to the relay…' })
144
- const uploaded = await flow.upload({ digest: reg.digest, deletable: false })
145
-
146
- // Upload landed (registered + stored + paid). Persist the certificate NOW so certify can be
147
- // completed later from My Blobs (after a tab switch / reload) if the user dismisses the prompt.
148
- deps.onUploaded?.({
149
- blobId: uploaded.blobId,
150
- blobObjectId: uploaded.blobObjectId,
151
- certificate: uploaded.certificate,
152
- deletable: false,
153
- })
154
-
155
- // Certify is a plain owner tx built from the storage-node certificate the live `flow` now holds
156
- // (no relay, no tip). If it fails (e.g. the user rejects the prompt) the blob is already registered
157
- // + stored + paid, so we never want to redo the upload — we expose this closure to retry certify
158
- // alone. Re-calling `flow.certify()` just rebuilds the same tx from the in-memory certificate.
159
- const runCertify = async (): Promise<UploadResult> => {
160
- deps.onStatus({ step: 'certify', detail: 'Certifying (approve in wallet)…' })
161
- const certTx = flow.certify()
162
- certTx.setSenderIfNotSet(deps.address)
163
- await certTx.build({ client: deps.suiClient })
164
- const cert = await deps.executor.signAndExecute(certTx)
165
- await deps.executor.waitForTransaction(cert.digest)
166
- const blob = await flow.getBlob()
167
- deps.onCertified?.(uploaded.blobObjectId) // certified → drop the persisted pending entry
168
- return { blobId: blob.blobId, url: walrusBlobUrl(deps.network, blob.blobId), digest: cert.digest }
169
- }
170
-
171
- try {
172
- return await runCertify()
173
- } catch (e) {
174
- // Upload landed but certify did not: attach a retry so the UI can offer a "Certify" button
175
- // rather than discarding the paid upload. The closure keeps the live flow (and its certificate).
176
- attachCertifyRetry<UploadResult>(e, runCertify)
177
- throw e
178
- }
179
- }