@meddleware/walrus-ui 0.1.56 → 0.1.58

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/CHANGELOG.md ADDED
@@ -0,0 +1,47 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@meddleware/walrus-ui` are documented here.
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
5
+ [Semantic Versioning](https://semver.org/). Before 0.2.0, breaking changes ship as patch releases.
6
+
7
+ ## [0.1.58] - 2026-10-18
8
+
9
+ ### Changed (breaking, pre-v0.2)
10
+
11
+ - **Adopts `@meddleware/walrus-relay` 0.1.31.** `useAccessGate` is given the network and a client getter
12
+ that serves the active network only; the access gate is still built by `relayGateConfig`, so it names the
13
+ published `access_gate` deployment. The purchase prompt (`AccessGateCta`) now shows the price, payees,
14
+ network and pass kind read from the chain, with a "Read the price again" button when they cannot be read.
15
+ - **`VITE_ACCESS_GATE_PRICE_MIST_TESTNET` is removed** (code, `.env.example`, Dockerfile build argument,
16
+ release workflow and README). The price is read from the gate on the chain; a build that still sets the
17
+ variable ignores it.
18
+ - `WalrusUpload` gets `sessionKey` (`<network>:<address>`), so a change of account or network discards the
19
+ chosen file and any held retry; the upload approvals hint follows the held pass (`singleUse`).
20
+ - Dependencies: `@meddleware/walrus-client` ^0.0.28, `@meddleware/walrus-relay` ^0.1.31,
21
+ `@meddleware/design-tokens` ^0.1.10, `@meddleware/access-gate-client` ^0.0.9. The unused
22
+ `@mysten/wallet-standard` dependency is dropped.
23
+ - Image base: `static-server` 0.1.8 (`sha256:e32cf42b…4000`).
24
+
25
+ ### Fixed
26
+
27
+ - **A slower owned-blob load no longer overwrites a newer one** after a wallet-account or network switch,
28
+ a disconnect, or a switch back to a list that is already held (a stale result, error or `loading` flag is
29
+ dropped); a cleared list no longer reads as loaded for the previous account.
30
+ - **Extend shows its cost before the wallet opens.** The seeded +10 epochs is priced on the first press
31
+ (the button reads "Show cost"), the amount and `≈ WAL` cost are shown, and the wallet opens on the next
32
+ press (`Extend`). An amount that cannot be priced says "price unavailable".
33
+ - **My Blobs text meets AA contrast in both themes.** It read `--mw-color-*` custom properties that
34
+ design-tokens does not define, so literal fallbacks (`#888`, `#c00`, `#ddd`, `#f90`) ignored theme and
35
+ season; it now uses `--muted`, `--danger`, `--border`, `--warning` (tint only) and `--accent`.
36
+ A test fails if a component reads a custom property design-tokens does not define.
37
+
38
+ ### Security
39
+
40
+ - `.env*` is git-ignored (except `.env.example`).
41
+ - `SECURITY.md` invariant 1 names the real wiring of the tip ceiling (`maxTipMist` to `runBlobUpload`).
42
+
43
+ ### Added
44
+
45
+ - `CHANGELOG.md` (it was listed in the package `files` without existing).
46
+ - Tests: stale-load guard (`useOwnedBlobs.stale`), extend quotes, the custom-property check, and the
47
+ gate-config/`useAccessGate` pairing.
package/README.md CHANGED
@@ -36,7 +36,6 @@ All `VITE_*` vars are baked into the static bundle at build time.
36
36
  | `VITE_WALRUS_RELAY_MAINNET` | Public Mysten relay | Operator relay URL for mainnet |
37
37
  | `VITE_ACCESS_GATE_ID_TESTNET` | — | Gate object ID (testnet; unset = no gate) |
38
38
  | `VITE_ACCESS_GATE_SOULBOUND_TESTNET` | `false` | `true` if NFTs are soulbound |
39
- | `VITE_ACCESS_GATE_PRICE_MIST_TESTNET` | `0` | Purchase price in MIST |
40
39
  | `VITE_ACCESS_GATE_ID_MAINNET` | — | Gate object ID (mainnet) |
41
40
  | `VITE_UPLOAD_RELAY_MAX_TIP_MIST` | `50000000` | Max relay tip cap in MIST (0.05 SUI) |
42
41
 
@@ -54,7 +53,6 @@ docker build \
54
53
  --build-arg VITE_NETWORK=testnet \
55
54
  --build-arg VITE_WALRUS_RELAY_TESTNET=https://sui-walrus-relay.example.com \
56
55
  --build-arg VITE_ACCESS_GATE_ID_TESTNET=0x... \
57
- --build-arg VITE_ACCESS_GATE_PRICE_MIST_TESTNET=100000000 \
58
56
  -t walrus-ui:latest .
59
57
  ```
60
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meddleware/walrus-ui",
3
- "version": "0.1.56",
3
+ "version": "0.1.58",
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>",
@@ -39,18 +39,19 @@
39
39
  "lint:css": "stylelint \"src/**/*.{css,vue}\"",
40
40
  "lint:js": "eslint .",
41
41
  "lint": "npm run lint:css && npm run lint:js && npm run lint:html",
42
- "lint:html": "html-validate \"src/**/*.vue\""
42
+ "lint:html": "html-validate \"src/**/*.vue\"",
43
+ "licenses": "node scripts/third-party-licenses.mjs",
44
+ "check:licenses": "node scripts/third-party-licenses.mjs --check"
43
45
  },
44
46
  "publishConfig": {
45
47
  "access": "public"
46
48
  },
47
49
  "dependencies": {
48
- "@meddleware/design-tokens": "^0.1.9",
50
+ "@meddleware/design-tokens": "^0.1.10",
49
51
  "@meddleware/ui": "^0.1.31",
50
- "@meddleware/walrus-client": "^0.0.27",
51
- "@meddleware/walrus-relay": "^0.1.30",
52
+ "@meddleware/walrus-client": "^0.0.28",
53
+ "@meddleware/walrus-relay": "^0.1.31",
52
54
  "@mysten/sui": "^2.33.2",
53
- "@mysten/wallet-standard": "^0.21.0",
54
55
  "@mysten/walrus": "~1.2.32",
55
56
  "@mysten/walrus-wasm": "~0.3.1",
56
57
  "vue": "^3.5.43"
@@ -59,7 +60,7 @@
59
60
  "@meddleware/wallet-adapter": ">=0.0.12 <0.2.0"
60
61
  },
61
62
  "devDependencies": {
62
- "@meddleware/access-gate-client": "^0.0.8",
63
+ "@meddleware/access-gate-client": "^0.0.9",
63
64
  "@meddleware/eslint-config": "^0.0.2",
64
65
  "@meddleware/wallet-adapter": "^0.0.17",
65
66
  "@playwright/test": "~1.63.0",
@@ -7,6 +7,7 @@ import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
7
7
  import type { Executor } from '../wallet.js'
8
8
  import { walrusNetwork, walruscanBlobUrl } from '../config.js'
9
9
  import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
10
+ import { useExtendQuotes } from '../composables/useExtendQuotes.js'
10
11
  import { groupBlobs, expiryLabel, maxExtendableEpochs, type BlobGroup } from '../blob-groups.js'
11
12
  import {
12
13
  pendingCertifyKey,
@@ -49,7 +50,6 @@ const pending = ref<Record<string, PendingCertify>>({})
49
50
  const busy = ref<string | null>(null)
50
51
  const actionStatus = ref<Record<string, string>>({})
51
52
  const extendAmount = ref<Record<string, number>>({})
52
- const extendCostFrost = ref<Record<string, bigint | null>>({})
53
53
 
54
54
  const MAX = MAX_SINGLE_RESERVATION_EPOCHS
55
55
 
@@ -104,35 +104,39 @@ function refreshPending(): void {
104
104
  }
105
105
 
106
106
  // ── Extend ──────────────────────────────────────────────────────────────────
107
- const extendReq: Record<string, number> = {}
107
+ // Extend adds storage only (no one-time write cost), so the quote prices the `storageCost` component.
108
+ async function estimateExtendCost(size: number, epochs: number): Promise<bigint | null> {
109
+ const { createWalrusClient, estimateStorageCost } = await import('@meddleware/walrus-client')
110
+ const walrusClient = createWalrusClient({ network: currentNetwork(), wasmUrl: walrusWasmUrl })
111
+ return (await estimateStorageCost(walrusClient, size, epochs)).storageCost
112
+ }
113
+ const extendQuotes = useExtendQuotes(estimateExtendCost)
108
114
 
109
- // User picked an amount → clamp and (re)price it. We estimate only on interaction, not for every row
110
- // on load, to avoid spinning up a Walrus client per blob.
115
+ // User picked an amount → clamp and (re)price it. Only the picked amount is priced here, not every
116
+ // row on load, to avoid spinning up a Walrus client per blob; a row that was never touched is priced
117
+ // when Extend is first pressed (see `extendBlob`), before the wallet opens.
111
118
  function setExtendAmount(blob: OwnedBlob, value: number | string): void {
112
119
  const max = maxAddable(blob.endEpoch)
113
120
  const v = Math.min(max, Math.max(1, Math.floor(Number(value) || 1)))
114
121
  extendAmount.value = { ...extendAmount.value, [blob.objectId]: v }
115
- void estimateExtend(blob, v)
122
+ void extendQuotes.price(blob.objectId, blob.size, v)
116
123
  }
117
- async function estimateExtend(blob: OwnedBlob, epochs: number): Promise<void> {
118
- const id = blob.objectId
119
- const token = (extendReq[id] = (extendReq[id] ?? 0) + 1)
120
- try {
121
- const { createWalrusClient, estimateStorageCost } = await import('@meddleware/walrus-client')
122
- const walrusClient = createWalrusClient({ network: currentNetwork(), wasmUrl: walrusWasmUrl })
123
- // Extend adds storage only (no one-time write cost), so price the `storageCost` component.
124
- const cost = await estimateStorageCost(walrusClient, blob.size, epochs)
125
- if (extendReq[id] === token) {
126
- extendCostFrost.value = { ...extendCostFrost.value, [id]: cost.storageCost }
127
- }
128
- } catch {
129
- if (extendReq[id] === token) extendCostFrost.value = { ...extendCostFrost.value, [id]: null }
130
- }
124
+
125
+ /** What the row shows beside the Extend button for its current amount. */
126
+ function extendQuoteLabel(blob: OwnedBlob): string {
127
+ const epochs = extendAmount.value[blob.objectId]
128
+ const q = extendQuotes.quoteFor(blob.objectId, epochs)
129
+ if (q?.state === 'ready') return `≈${formatCoinAmount(q.costFrost, 'WAL')} for +${q.epochs} epochs`
130
+ if (q?.state === 'unavailable') return 'price unavailable'
131
+ return extendQuotes.pricing.value[blob.objectId] === epochs ? 'pricing…' : ''
131
132
  }
132
133
 
133
134
  async function extendBlob(blob: OwnedBlob): Promise<void> {
134
135
  const epochs = extendAmount.value[blob.objectId]
135
136
  if (!epochs || busy.value) return
137
+ // The wallet opens only for an amount whose cost the user has been shown. The seeded default has
138
+ // no quote yet: price it now, show it, and let the user press Extend again to confirm.
139
+ if (!(await extendQuotes.shownBeforeSigning(blob.objectId, blob.size, epochs))) return
136
140
  busy.value = blob.objectId
137
141
  actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Building transaction…' }
138
142
  try {
@@ -143,6 +147,7 @@ async function extendBlob(blob: OwnedBlob): Promise<void> {
143
147
  actionStatus.value = { ...actionStatus.value, [blob.objectId]: 'Approve in wallet…' }
144
148
  const { digest } = await executor.signAndExecute(tx)
145
149
  await executor.waitForTransaction(digest)
150
+ extendQuotes.clear(blob.objectId)
146
151
  actionStatus.value = { ...actionStatus.value, [blob.objectId]: `Extended ✓ (${digest.slice(0, 8)}…)` }
147
152
  await refresh()
148
153
  } catch (e) {
@@ -298,10 +303,10 @@ watch(
298
303
  >
299
304
  Max
300
305
  </button>
301
- <button type="button" :disabled="!!busy" @click="extendBlob(g.representative)">Extend</button>
302
- <span v-if="extendCostFrost[g.representative.objectId] != null" class="est">
303
- ≈{{ formatCoinAmount(extendCostFrost[g.representative.objectId]!, 'WAL') }}
304
- </span>
306
+ <button type="button" :disabled="!!busy" @click="extendBlob(g.representative)">
307
+ {{ extendQuotes.quoteFor(g.representative.objectId, extendAmount[g.representative.objectId]) ? 'Extend' : 'Show cost' }}
308
+ </button>
309
+ <span class="est" aria-live="polite">{{ extendQuoteLabel(g.representative) }}</span>
305
310
  </span>
306
311
  </template>
307
312
  </td>
@@ -355,11 +360,11 @@ watch(
355
360
  font-size: 1.2rem;
356
361
  }
357
362
  .hint {
358
- color: var(--mw-color-text-muted, #888);
363
+ color: var(--muted);
359
364
  font-size: 0.9rem;
360
365
  }
361
366
  .err {
362
- color: var(--mw-color-danger, #c00);
367
+ color: var(--danger);
363
368
  font-size: 0.9rem;
364
369
  }
365
370
  .blob-table {
@@ -371,21 +376,21 @@ watch(
371
376
  .blob-table td {
372
377
  text-align: left;
373
378
  padding: 0.4rem 0.6rem;
374
- border-bottom: 1px solid var(--mw-color-border, #ddd);
379
+ border-bottom: 1px solid var(--border);
375
380
  vertical-align: top;
376
381
  }
377
382
  .blob-table th {
378
383
  font-weight: 600;
379
- color: var(--mw-color-text-muted, #888);
384
+ color: var(--muted);
380
385
  }
381
386
  .blob-table tr.warn td {
382
- background: color-mix(in srgb, var(--mw-color-warning, #f90) 8%, transparent);
387
+ background: color-mix(in srgb, var(--warning) 8%, transparent);
383
388
  }
384
389
  .blob-table tr.highlight td {
385
- background: color-mix(in srgb, var(--accent, #6366f1) 14%, transparent);
390
+ background: color-mix(in srgb, var(--accent) 14%, transparent);
386
391
  }
387
392
  .copy-row td {
388
- background: color-mix(in srgb, var(--mw-color-text-muted, #888) 6%, transparent);
393
+ background: color-mix(in srgb, var(--muted) 6%, transparent);
389
394
  font-size: 0.82rem;
390
395
  }
391
396
  .copies-toggle {
@@ -415,23 +420,23 @@ watch(
415
420
  }
416
421
  .est {
417
422
  font-size: 0.78rem;
418
- color: var(--mw-color-text-muted, #888);
423
+ color: var(--muted);
419
424
  }
420
425
  .at-max {
421
426
  font-size: 0.8rem;
422
- color: var(--mw-color-text-muted, #888);
427
+ color: var(--muted);
423
428
  }
424
429
  .act-status {
425
430
  font-size: 0.85rem;
426
- color: var(--mw-color-text-muted, #888);
431
+ color: var(--muted);
427
432
  }
428
433
  .certify-btn {
429
- border-color: var(--accent, #6366f1);
430
- color: var(--accent, #6366f1);
434
+ border-color: var(--accent);
435
+ color: var(--accent);
431
436
  }
432
437
  .pending-badge {
433
438
  font-size: 0.78rem;
434
- color: var(--accent, #6366f1);
439
+ color: var(--accent);
435
440
  font-weight: 600;
436
441
  }
437
442
  </style>
@@ -9,6 +9,7 @@ import {
9
9
  WalrusUpload,
10
10
  AccessGateCta,
11
11
  useAccessGate,
12
+ type WalrusNetwork,
12
13
  } from '@meddleware/walrus-relay'
13
14
  import { AppTabNav, CopyableAddress, ExplorerLink, UiNotice, UiTabPanel, UiToolIntro, suiExplorerUrl, safeHref, type AppTab } from '@meddleware/ui'
14
15
  // Lightweight URL import — just the wasm asset URL (does not pull the walrus client).
@@ -45,9 +46,16 @@ const { account, signPersonalMessage, buildExecutor } = useWallet()
45
46
 
46
47
  // One access-gate state per Walrus network (the gate is a per-network setting); the active
47
48
  // network's is used. Each is inert until an ownership check runs.
49
+ // `useAccessGate` throws for a gate that is not the published deployment on its network;
50
+ // `accessGate()` only returns such configs (or null, ungated). The shared client serves the active
51
+ // network only, so a state for another network refuses to read rather than reading the wrong chain.
52
+ function gateClient(net: WalrusNetwork) {
53
+ if (net !== network.value) throw new Error(`No Sui client for ${net} while ${network.value} is active.`)
54
+ return getSuiClient()
55
+ }
48
56
  const gateStates = {
49
- testnet: useAccessGate({ gate: accessGate('testnet'), getClient: () => getSuiClient() }),
50
- mainnet: useAccessGate({ gate: accessGate('mainnet'), getClient: () => getSuiClient() }),
57
+ testnet: useAccessGate({ network: 'testnet', gate: accessGate('testnet'), getClient: gateClient }),
58
+ mainnet: useAccessGate({ network: 'mainnet', gate: accessGate('mainnet'), getClient: gateClient }),
51
59
  }
52
60
  /** The active network's gate state (testnet's while the view is hidden on a non-Walrus network). */
53
61
  const gateState = computed(() => gateStates[walrusNetwork.value ?? 'testnet'])
@@ -75,6 +83,8 @@ async function onPurchase(): Promise<void> {
75
83
  try {
76
84
  const executor = await buildExecutor()
77
85
  await gateState.value.purchase(executor, account.value.address)
86
+ } catch {
87
+ // The reason is published in `purchaseError` and shown by the prompt.
78
88
  } finally {
79
89
  purchasing.value = false
80
90
  }
@@ -237,9 +247,19 @@ function onSettled(): void {
237
247
  :gate-configured="gateState.gateConfigured"
238
248
  :has-access="gateState.hasAccess.value"
239
249
  :busy="purchasing"
240
- :price-mist="gateState.gate?.priceMist ?? null"
250
+ :terms="gateState.terms.value"
251
+ :terms-error="gateState.termsError.value"
252
+ :purchase-error="gateState.purchaseError.value"
241
253
  @purchase="onPurchase"
242
254
  />
255
+ <button
256
+ v-if="gateState.termsError.value && !gateState.termsLoading.value"
257
+ type="button"
258
+ class="retry-terms"
259
+ @click="gateState.loadTerms()"
260
+ >
261
+ Read the price again
262
+ </button>
243
263
  </div>
244
264
 
245
265
  <!-- Ownership check still in flight. -->
@@ -266,7 +286,8 @@ function onSettled(): void {
266
286
  <WalrusUpload
267
287
  :hosts="relayHosts(walrusNetwork)"
268
288
  :connected="!!account"
269
- :access="{ gateConfigured: gateState.gateConfigured, hasAccess: gateState.hasAccess }"
289
+ :access="{ gateConfigured: gateState.gateConfigured, hasAccess: gateState.hasAccess, singleUse: gateState.singleUse }"
290
+ :session-key="`${walrusNetwork}:${account?.address ?? ''}`"
270
291
  :perform-upload="performUpload"
271
292
  :estimate-storage-cost="estimateUploadStorageCost"
272
293
  @uploaded="onUploaded"
@@ -0,0 +1,69 @@
1
+ // Prices an Extend before the wallet opens (audit F12). A row's quote is the WAL cost of adding
2
+ // `epochs`; Extend opens the wallet only for an amount whose quote the user has been shown. A quote is
3
+ // an estimate (labelled `≈`): the wallet and the chain still decide the amount actually paid.
4
+ import { ref } from 'vue'
5
+
6
+ export type ExtendQuote =
7
+ | { epochs: number; state: 'ready'; costFrost: bigint }
8
+ | { epochs: number; state: 'unavailable'; costFrost: null }
9
+
10
+ /** Cost in FROST of storing `size` bytes for `epochs`, or `null` when it cannot be priced. */
11
+ export type EstimateExtendCost = (size: number, epochs: number) => Promise<bigint | null>
12
+
13
+ export function useExtendQuotes(estimate: EstimateExtendCost) {
14
+ /** Latest quote per Blob object id (one per row). */
15
+ const quotes = ref<Record<string, ExtendQuote>>({})
16
+ const pricing = ref<Record<string, number>>({})
17
+ /** Per-row request counter: an older answer never replaces a newer amount's quote. */
18
+ const tokens: Record<string, number> = {}
19
+
20
+ /** Price `epochs` for the row; resolves to its quote, or `null` if a newer request replaced this one. */
21
+ async function price(id: string, size: number, epochs: number): Promise<ExtendQuote | null> {
22
+ const token = (tokens[id] = (tokens[id] ?? 0) + 1)
23
+ pricing.value = { ...pricing.value, [id]: epochs }
24
+ let cost: bigint | null
25
+ try {
26
+ cost = await estimate(size, epochs)
27
+ } catch {
28
+ cost = null
29
+ }
30
+ if (tokens[id] !== token) return null
31
+ const rest = { ...pricing.value }
32
+ delete rest[id]
33
+ pricing.value = rest
34
+ const quote: ExtendQuote =
35
+ cost === null ? { epochs, state: 'unavailable', costFrost: null } : { epochs, state: 'ready', costFrost: cost }
36
+ quotes.value = { ...quotes.value, [id]: quote }
37
+ return quote
38
+ }
39
+
40
+ /** The row's quote, only if it is for exactly `epochs` (an older amount's quote is not shown). */
41
+ function quoteFor(id: string, epochs: number | undefined): ExtendQuote | null {
42
+ const q = quotes.value[id]
43
+ return q && q.epochs === epochs ? q : null
44
+ }
45
+
46
+ /**
47
+ * Whether Extend may open the wallet now: only when the quote for `epochs` has been shown. If it has
48
+ * not (the seeded default, or a price still loading), it is fetched now, so the user sees the cost
49
+ * and presses Extend again — never a wallet prompt for an unpriced amount.
50
+ */
51
+ async function shownBeforeSigning(id: string, size: number, epochs: number): Promise<boolean> {
52
+ if (quoteFor(id, epochs)) return true
53
+ await price(id, size, epochs)
54
+ return false
55
+ }
56
+
57
+ /** Forget the row's quote (after its blob was extended, the next Extend is priced afresh). */
58
+ function clear(id: string): void {
59
+ tokens[id] = (tokens[id] ?? 0) + 1
60
+ const q = { ...quotes.value }
61
+ delete q[id]
62
+ quotes.value = q
63
+ const p = { ...pricing.value }
64
+ delete p[id]
65
+ pricing.value = p
66
+ }
67
+
68
+ return { quotes, pricing, price, quoteFor, shownBeforeSigning, clear }
69
+ }
@@ -20,18 +20,41 @@ const error = ref<string | null>(null)
20
20
  /** `<network>|<address>` the current `blobs` were fetched for; `null` until a successful load. */
21
21
  const loadedFor = ref<string | null>(null)
22
22
 
23
+ /** Counts calls that decide what the list shows; a result is applied only if it is still the latest. */
24
+ let latest = 0
25
+ /** Key of the load in flight (the latest one), or `null` when none is. */
26
+ let inflightKey: string | null = null
27
+
28
+ /** Drop whatever load is in flight: its result will be discarded and it no longer owns `loading`. */
29
+ function supersede(): void {
30
+ latest++
31
+ inflightKey = null
32
+ loading.value = false
33
+ }
34
+
23
35
  async function load(address: string | null, opts: { force?: boolean } = {}): Promise<void> {
24
36
  const net = walrusNetwork.value
25
37
  if (!address || !net) {
38
+ supersede()
26
39
  blobs.value = []
27
40
  loadedFor.value = null
28
41
  return
29
42
  }
30
43
  // Skip refetch when we already have this address's list and the last load succeeded.
31
44
  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
45
+ if (!opts.force && loadedFor.value === key && error.value === null) {
46
+ // Switching back to a list we hold: a slower load for another account or network must not land.
47
+ if (inflightKey !== null && inflightKey !== key) supersede()
48
+ return
49
+ }
50
+ if (loadedFor.value !== key) {
51
+ // Never show another network's or wallet's list; and a cleared list must not read as loaded.
52
+ blobs.value = []
53
+ loadedFor.value = null
54
+ }
34
55
 
56
+ const mine = ++latest
57
+ inflightKey = key
35
58
  loading.value = true
36
59
  error.value = null
37
60
  try {
@@ -45,13 +68,20 @@ async function load(address: string | null, opts: { force?: boolean } = {}): Pro
45
68
  walrusClient.walrus.systemState(),
46
69
  fetchOwnedWalrusBlobs(suiClient, walrusClient, address),
47
70
  ])
71
+ // A newer load (or a switch) took over while this one was in flight: its answer is for another
72
+ // account or network, so drop it without touching the list, the error or `loading`.
73
+ if (mine !== latest) return
48
74
  currentEpoch.value = Number(sys.committee.epoch)
49
75
  blobs.value = fetched.sort((a: OwnedBlob, b: OwnedBlob) => a.endEpoch - b.endEpoch)
50
76
  loadedFor.value = key
51
77
  } catch (e) {
78
+ if (mine !== latest) return
52
79
  error.value = e instanceof Error ? e.message : String(e)
53
80
  } finally {
54
- loading.value = false
81
+ if (mine === latest) {
82
+ loading.value = false
83
+ inflightKey = null
84
+ }
55
85
  }
56
86
  }
57
87
 
package/src/config.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  // selects VITE_NETWORK in main.ts; embedded, the host's selector rules). Operator settings are
3
3
  // build-time VITE_* per network: relay hostnames default to the public Mysten relay (set
4
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.
5
+ // (unset ⇒ ungated), and its package + PlatformConfig come from the published deployment; the
6
+ // purchase price and payees are read from the chain, never configured.
6
7
  import { computed } from 'vue'
7
8
  import type { WalrusNetwork, RelayGateConfig } from '@meddleware/walrus-relay'
8
9
  import { relayGateConfig } from '@meddleware/walrus-relay'
@@ -64,9 +65,10 @@ export function uploadRelayMaxTipMist(envSource: EnvSource = env): number {
64
65
  }
65
66
 
66
67
  /**
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
68
+ * The optional NFT access gate for a network: the operator supplies only the Gate id and soulbound
69
+ * flag (`VITE_ACCESS_GATE_*_{NET}`); `relayGateConfig` fixes the package and `PlatformConfig` to the
70
+ * published deployment (commission enforcement), and the price is read from the gate on the chain.
71
+ * `null` — an ungated
70
72
  * relay — when no gate id is set, or when no access_gate deployment is recorded for the network.
71
73
  */
72
74
  export function accessGate(network: WalrusNetwork, envSource: EnvSource = env): RelayGateConfig | null {
@@ -77,7 +79,6 @@ export function accessGate(network: WalrusNetwork, envSource: EnvSource = env):
77
79
  return relayGateConfig(network, {
78
80
  gateId,
79
81
  soulbound: envSource[`VITE_ACCESS_GATE_SOULBOUND_${NET}`] === 'true',
80
- priceMist: BigInt(envSource[`VITE_ACCESS_GATE_PRICE_MIST_${NET}`] || '0'),
81
82
  })
82
83
  } catch (e) {
83
84
  console.warn(`[walrus-ui] access gate for ${network} disabled: ${e instanceof Error ? e.message : String(e)}`)