@meddleware/walrus-ui 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,14 @@
1
+ BSD Zero Clause License
2
+
3
+ Copyright (c) 2026 MeddleWare
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted.
7
+
8
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
9
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
10
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
11
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
12
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
13
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
14
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # @meddleware/walrus-ui
2
+
3
+ [![License: 0BSD](https://img.shields.io/badge/license-0BSD-blue)](LICENSE)
4
+
5
+ A Vue 3 SPA for uploading and managing blobs on [Walrus](https://walrus.xyz) decentralised
6
+ storage on Sui. Supports NFT-gated operator relays (commission-enforced on-chain via
7
+ `access_gate`) with automatic fallback to the public Mysten relay.
8
+
9
+ ## Features
10
+
11
+ - Connect any Sui wallet (wallet-standard)
12
+ - Select between operator relay (supports the app) and public relay (free)
13
+ - Upload blobs through an NFT-gated or open relay
14
+ - View and extend your owned Walrus blobs
15
+ - Two-approval upload flow: register tx → upload → certify tx
16
+
17
+ ## Local development
18
+
19
+ ```bash
20
+ npm install
21
+ npm run dev
22
+ ```
23
+
24
+ > `@meddleware/walrus-relay` is resolved from the npm registry. For local development against an
25
+ > unpublished library, use `npm link @meddleware/walrus-relay` or an `overrides` entry pointing at
26
+ > a local checkout.
27
+
28
+ ## Environment variables
29
+
30
+ All `VITE_*` vars are baked into the static bundle at build time.
31
+
32
+ | Variable | Default | Description |
33
+ | --- | --- | --- |
34
+ | `VITE_NETWORK` | `testnet` | `testnet` or `mainnet` |
35
+ | `VITE_WALRUS_RELAY_TESTNET` | Public Mysten relay | Operator relay URL for testnet |
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
+ | `VITE_ACCESS_GATE_ID_TESTNET` | — | Gate object ID (testnet; unset = no gate) |
40
+ | `VITE_ACCESS_GATE_SOULBOUND_TESTNET` | `false` | `true` if NFTs are soulbound |
41
+ | `VITE_ACCESS_GATE_PRICE_MIST_TESTNET` | `0` | Purchase price in MIST |
42
+ | `VITE_ACCESS_GATE_ID_MAINNET` | — | Gate object ID (mainnet) |
43
+ | `VITE_UPLOAD_RELAY_MAX_TIP_MIST` | `50000000` | Max relay tip cap in MIST (0.05 SUI) |
44
+
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.
47
+
48
+ ## Docker build
49
+
50
+ The Docker context is this repo root. `@meddleware/walrus-relay` resolves from the npm registry,
51
+ so the library must be published before building the image.
52
+
53
+ ```bash
54
+ docker build \
55
+ --build-arg VITE_NETWORK=testnet \
56
+ --build-arg VITE_WALRUS_RELAY_TESTNET=https://sui-walrus-relay.example.com \
57
+ --build-arg VITE_ACCESS_GATE_ID_TESTNET=0x... \
58
+ --build-arg VITE_ACCESS_GATE_PRICE_MIST_TESTNET=100000000 \
59
+ -t walrus-ui:latest .
60
+ ```
61
+
62
+ ## Architecture
63
+
64
+ Thin SPA — no accounting logic, no chain state derivation. The app:
65
+ 1. Reads config from env vars
66
+ 2. Wires `@meddleware/walrus-relay` composables (`useAccessGate`, `useWalrusRelay`) to the
67
+ connected wallet
68
+ 3. Passes a `performUpload` callback to `WalrusUpload`, which holds the `@mysten/walrus` client
69
+ and the upload flow logic
70
+ 4. Displays results
71
+
72
+ All financial truth is on-chain. This UI is an orchestration shell.
73
+
74
+ ## License
75
+
76
+ 0BSD
package/index.html ADDED
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Walrus Uploader · MeddleWare</title>
7
+ <meta name="description" content="Upload blobs to Walrus decentralised storage on Sui through the MeddleWare relay. Client-side; your wallet signs and pays." />
8
+ </head>
9
+ <body>
10
+ <div id="app"></div>
11
+ <script type="module" src="/src/main.ts"></script>
12
+ </body>
13
+ </html>
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@meddleware/walrus-ui",
3
+ "version": "0.1.0",
4
+ "description": "Standalone Vue 3 SPA for uploading blobs to Walrus decentralised storage and managing owned blobs — wallet-connected, NFT-gated relay support.",
5
+ "author": "MeddleWare <meddleware@proton.me>",
6
+ "license": "0BSD",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/meddleware-org/walrus-ui.git"
10
+ },
11
+ "type": "module",
12
+ "files": [
13
+ "src",
14
+ "index.html",
15
+ "vite.config.ts",
16
+ "tsconfig.json",
17
+ "CHANGELOG.md"
18
+ ],
19
+ "scripts": {
20
+ "dev": "vite",
21
+ "build": "vue-tsc --noEmit && vite build",
22
+ "preview": "vite preview",
23
+ "type-check": "vue-tsc --noEmit"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "dependencies": {
29
+ "@meddleware/design-tokens": "^0.1.0",
30
+ "@meddleware/nft-gate-client": "^0.0.3",
31
+ "@meddleware/walrus-client": "^0.0.2",
32
+ "@meddleware/walrus-relay": "^0.1.0",
33
+ "@mysten/sui": "~2.17.0",
34
+ "@mysten/wallet-standard": "~0.19.9",
35
+ "@mysten/walrus": "~1.1.7",
36
+ "@mysten/walrus-wasm": "~0.2.2",
37
+ "vue": "^3.5.40"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "~24.12.2",
41
+ "@vitejs/plugin-vue": "^6.0.8",
42
+ "typescript": "~6.0.0",
43
+ "vite": "^8.1.5",
44
+ "vue-tsc": "~3.3.0"
45
+ },
46
+ "engines": {
47
+ "node": "^22.18.0 || >=24.12.0"
48
+ }
49
+ }
package/src/App.vue ADDED
@@ -0,0 +1,256 @@
1
+ <script setup lang="ts">
2
+ import { ref } from 'vue'
3
+ import {
4
+ WalrusUpload,
5
+ TipConfigBadge,
6
+ AccessGateCta,
7
+ useAccessGate,
8
+ MAX_SINGLE_RESERVATION_EPOCHS,
9
+ } from '@meddleware/walrus-relay'
10
+ import type { UploadResult } from '@meddleware/walrus-relay'
11
+ // Lightweight URL import — just the wasm asset URL (does not pull the walrus client).
12
+ import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
13
+ import { useWallet, getSuiClient } from './wallet.js'
14
+ import { NETWORK, OPERATOR_RELAY_HOSTS, relayHosts, accessGate } from './config.js'
15
+ import MyBlobs from './components/MyBlobs.vue'
16
+
17
+ type Tab = 'upload' | 'blobs'
18
+ const activeTab = ref<Tab>('upload')
19
+
20
+ const { wallets, account, connect, disconnect, signPersonalMessage, buildExecutor } = useWallet()
21
+
22
+ const gate = accessGate(NETWORK)
23
+ const gateState = useAccessGate({ gate, getClient: () => getSuiClient(NETWORK) })
24
+ const purchasing = ref(false)
25
+ const result = ref<UploadResult | null>(null)
26
+
27
+ async function onConnectFirst(): Promise<void> {
28
+ const w = wallets.value[0]
29
+ if (w) {
30
+ await connect(w)
31
+ if (account.value) await gateState.checkOwnership(account.value.address)
32
+ }
33
+ }
34
+
35
+ async function onPurchase(): Promise<void> {
36
+ if (!account.value) return
37
+ purchasing.value = true
38
+ try {
39
+ const executor = await buildExecutor(NETWORK)
40
+ await gateState.purchase(executor, account.value.address)
41
+ } finally {
42
+ purchasing.value = false
43
+ }
44
+ }
45
+
46
+ // Wire the shared WalrusUpload widget to @meddleware/walrus-client + the wallet.
47
+ async function performUpload(
48
+ bytes: Uint8Array,
49
+ opts: { relayHost: string; onStatus: (s: string) => void },
50
+ ): Promise<UploadResult> {
51
+ if (!account.value) throw new Error('Connect your wallet first.')
52
+ const { createWalrusClient, createBlobUploadFlow, walrusBlobUrl } = await import('@meddleware/walrus-client')
53
+ const executor = await buildExecutor(NETWORK)
54
+ const suiClient = getSuiClient(NETWORK)
55
+
56
+ // If the relay is NFT-gated and we hold access, attach a signed proof token.
57
+ let authToken: string | undefined
58
+ if (gate && gateState.hasAccess.value === true) {
59
+ authToken = await gateState.buildRelayAccessToken({
60
+ relayHost: opts.relayHost,
61
+ address: account.value.address,
62
+ sign: signPersonalMessage,
63
+ })
64
+ }
65
+
66
+ const client = createWalrusClient({
67
+ network: NETWORK,
68
+ wasmUrl: walrusWasmUrl,
69
+ uploadRelayHost: opts.relayHost,
70
+ uploadRelayAuthToken: authToken,
71
+ uploadRelayMaxTipMist: Number(import.meta.env.VITE_UPLOAD_RELAY_MAX_TIP_MIST) || 50_000_000,
72
+ })
73
+ const flow = createBlobUploadFlow(client, bytes)
74
+
75
+ opts.onStatus('Encoding…')
76
+ await flow.encode()
77
+
78
+ opts.onStatus('Registering blob (approve in wallet)…')
79
+ const regTx = flow.register({
80
+ owner: account.value.address,
81
+ epochs: MAX_SINGLE_RESERVATION_EPOCHS,
82
+ deletable: false,
83
+ })
84
+ regTx.setSenderIfNotSet(account.value.address)
85
+ await regTx.build({ client: suiClient })
86
+ const reg = await executor.signAndExecute(regTx)
87
+ await executor.waitForTransaction(reg.digest)
88
+
89
+ opts.onStatus('Uploading to the relay…')
90
+ await flow.upload({ digest: reg.digest })
91
+
92
+ opts.onStatus('Certifying (approve in wallet)…')
93
+ const certTx = flow.certify()
94
+ certTx.setSenderIfNotSet(account.value.address)
95
+ await certTx.build({ client: suiClient })
96
+ const cert = await executor.signAndExecute(certTx)
97
+ await executor.waitForTransaction(cert.digest)
98
+
99
+ const blob = await flow.getBlob()
100
+ return { blobId: blob.blobId, url: walrusBlobUrl(NETWORK, blob.blobId), digest: cert.digest }
101
+ }
102
+
103
+ function onUploaded(r: UploadResult): void {
104
+ result.value = r
105
+ }
106
+ </script>
107
+
108
+ <template>
109
+ <div class="page">
110
+ <header class="head">
111
+ <div>
112
+ <h1>Walrus Assets</h1>
113
+ <p class="sub">Upload and manage blobs on Walrus decentralised storage ({{ NETWORK }}).</p>
114
+ </div>
115
+ <TipConfigBadge :host="OPERATOR_RELAY_HOSTS[NETWORK]" />
116
+ </header>
117
+
118
+ <nav class="tabs" aria-label="Feature tabs">
119
+ <button
120
+ type="button"
121
+ class="tab"
122
+ :class="{ active: activeTab === 'upload' }"
123
+ @click="activeTab = 'upload'"
124
+ >
125
+ Upload
126
+ </button>
127
+ <button
128
+ type="button"
129
+ class="tab"
130
+ :class="{ active: activeTab === 'blobs' }"
131
+ @click="activeTab = 'blobs'"
132
+ >
133
+ My Blobs
134
+ </button>
135
+ </nav>
136
+
137
+ <section class="wallet">
138
+ <template v-if="account">
139
+ <span class="addr">{{ account.address.slice(0, 8) }}…{{ account.address.slice(-4) }}</span>
140
+ <button type="button" @click="disconnect">Disconnect</button>
141
+ </template>
142
+ <template v-else>
143
+ <button type="button" :disabled="!wallets.length" @click="onConnectFirst">
144
+ {{ wallets.length ? 'Connect wallet' : 'No wallet detected' }}
145
+ </button>
146
+ </template>
147
+ </section>
148
+
149
+ <template v-if="activeTab === 'upload'">
150
+ <AccessGateCta
151
+ :gate-configured="gateState.gateConfigured"
152
+ :has-access="gateState.hasAccess.value"
153
+ :busy="purchasing"
154
+ :price-mist="gate?.priceMist ?? null"
155
+ @purchase="onPurchase"
156
+ />
157
+
158
+ <WalrusUpload
159
+ :hosts="relayHosts(NETWORK)"
160
+ :connected="!!account"
161
+ :access="{ gateConfigured: gateState.gateConfigured, hasAccess: gateState.hasAccess }"
162
+ :perform-upload="performUpload"
163
+ @uploaded="onUploaded"
164
+ />
165
+
166
+ <section v-if="result" class="result">
167
+ <h2>Uploaded ✓</h2>
168
+ <p><strong>Blob ID:</strong> <code>{{ result.blobId }}</code></p>
169
+ <p>
170
+ <strong>URL:</strong>
171
+ <a :href="result.url" target="_blank" rel="noopener">{{ result.url }}</a>
172
+ </p>
173
+ <p v-if="result.digest"><strong>Certify tx:</strong> <code>{{ result.digest }}</code></p>
174
+ </section>
175
+ </template>
176
+
177
+ <MyBlobs
178
+ v-if="activeTab === 'blobs'"
179
+ :address="account?.address ?? null"
180
+ :build-executor="() => buildExecutor(NETWORK)"
181
+ />
182
+
183
+ <footer class="foot">
184
+ <p>© MeddleWare · <a href="https://sui.meddleware.co.uk">more SUI tools</a></p>
185
+ </footer>
186
+ </div>
187
+ </template>
188
+
189
+ <style scoped>
190
+ .page {
191
+ max-width: 640px;
192
+ margin: 0 auto;
193
+ padding: 2rem 1.25rem 4rem;
194
+ }
195
+ .head {
196
+ display: flex;
197
+ justify-content: space-between;
198
+ align-items: flex-start;
199
+ gap: 1rem;
200
+ flex-wrap: wrap;
201
+ }
202
+ .head h1 {
203
+ margin: 0;
204
+ font-size: 1.8rem;
205
+ }
206
+ .sub {
207
+ color: var(--mw-color-text-muted, #666);
208
+ margin: 0.25rem 0 0;
209
+ }
210
+ .wallet {
211
+ display: flex;
212
+ align-items: center;
213
+ gap: 0.75rem;
214
+ margin: 1.25rem 0;
215
+ }
216
+ .addr {
217
+ font-family: monospace;
218
+ font-size: 0.9rem;
219
+ }
220
+ .result {
221
+ margin-top: 1.5rem;
222
+ padding: 1rem;
223
+ border: 1px solid var(--mw-color-border, #ddd);
224
+ border-radius: 10px;
225
+ word-break: break-all;
226
+ }
227
+ .result code {
228
+ font-size: 0.85rem;
229
+ }
230
+ .tabs {
231
+ display: flex;
232
+ gap: 0.25rem;
233
+ margin: 1rem 0 0.5rem;
234
+ border-bottom: 2px solid var(--mw-color-border, #ddd);
235
+ }
236
+ .tab {
237
+ background: none;
238
+ border: none;
239
+ border-bottom: 2px solid transparent;
240
+ padding: 0.5rem 1rem;
241
+ margin-bottom: -2px;
242
+ cursor: pointer;
243
+ font-size: 0.95rem;
244
+ color: var(--mw-color-text-muted, #666);
245
+ }
246
+ .tab.active {
247
+ border-bottom-color: var(--mw-color-primary, #6c3);
248
+ color: var(--mw-color-text, #111);
249
+ font-weight: 600;
250
+ }
251
+ .foot {
252
+ margin-top: 3rem;
253
+ font-size: 0.85rem;
254
+ color: var(--mw-color-text-muted, #666);
255
+ }
256
+ </style>
@@ -0,0 +1,188 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch } from 'vue'
3
+ import type { OwnedBlob } from '@meddleware/walrus-client'
4
+ import walrusWasmUrl from '@mysten/walrus-wasm/web/walrus_wasm_bg.wasm?url'
5
+ import { getSuiClient } from '../wallet.js'
6
+ import type { Executor } from '../wallet.js'
7
+ import { NETWORK } from '../config.js'
8
+
9
+ const props = defineProps<{
10
+ /** Connected wallet address whose owned blobs to list; `null` when no wallet is connected. */
11
+ address: string | null
12
+ /** Factory that builds a transaction {@link Executor} bound to the connected wallet (for extend). */
13
+ buildExecutor: () => Promise<Executor>
14
+ }>()
15
+
16
+ const blobs = ref<OwnedBlob[]>([])
17
+ const loading = ref(false)
18
+ const error = ref<string | null>(null)
19
+ const currentEpoch = ref(0)
20
+ const extending = ref<string | null>(null)
21
+ const extendStatus = ref<Record<string, string>>({})
22
+
23
+ const EXPIRY_WARN_EPOCHS = 10
24
+ const EPOCHS_PER_DAY = 1 / 0.038 // ~1 Walrus epoch ≈ 38 minutes on testnet
25
+
26
+ function epochsToApproxDays(epochs: number): string {
27
+ const days = Math.round(epochs * EPOCHS_PER_DAY)
28
+ if (days <= 0) return 'expired'
29
+ if (days < 2) return `${days} day`
30
+ return `${days} days`
31
+ }
32
+
33
+ async function loadBlobs(): Promise<void> {
34
+ if (!props.address) return
35
+ loading.value = true
36
+ error.value = null
37
+ blobs.value = []
38
+ try {
39
+ const { createWalrusClient, fetchOwnedWalrusBlobs } = await import('@meddleware/walrus-client')
40
+ const suiClient = getSuiClient(NETWORK)
41
+ const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
42
+ const [sys, fetched] = await Promise.all([
43
+ suiClient.getLatestSuiSystemState(),
44
+ fetchOwnedWalrusBlobs(suiClient, walrusClient, props.address),
45
+ ])
46
+ currentEpoch.value = Number(sys.epoch)
47
+ blobs.value = fetched.sort((a, b) => a.endEpoch - b.endEpoch)
48
+ } catch (e) {
49
+ error.value = e instanceof Error ? e.message : String(e)
50
+ } finally {
51
+ loading.value = false
52
+ }
53
+ }
54
+
55
+ async function extendBlob(blob: OwnedBlob): Promise<void> {
56
+ extending.value = blob.objectId
57
+ extendStatus.value = { ...extendStatus.value, [blob.objectId]: 'Building transaction…' }
58
+ try {
59
+ const { createWalrusClient, extendBlobLifetimeTransaction } = await import('@meddleware/walrus-client')
60
+ const walrusClient = createWalrusClient({ network: NETWORK, wasmUrl: walrusWasmUrl })
61
+ const tx = await extendBlobLifetimeTransaction(walrusClient, blob.objectId, { epochs: 10 })
62
+ const executor = await props.buildExecutor()
63
+ extendStatus.value = { ...extendStatus.value, [blob.objectId]: 'Approve in wallet…' }
64
+ const { digest } = await executor.signAndExecute(tx)
65
+ await executor.waitForTransaction(digest)
66
+ extendStatus.value = { ...extendStatus.value, [blob.objectId]: `Extended ✓ (${digest.slice(0, 8)}…)` }
67
+ // Refresh the list so the new endEpoch is visible.
68
+ await loadBlobs()
69
+ } catch (e) {
70
+ extendStatus.value = {
71
+ ...extendStatus.value,
72
+ [blob.objectId]: `Failed: ${e instanceof Error ? e.message : String(e)}`,
73
+ }
74
+ } finally {
75
+ extending.value = null
76
+ }
77
+ }
78
+
79
+ watch(() => props.address, loadBlobs)
80
+ </script>
81
+
82
+ <template>
83
+ <section class="my-blobs">
84
+ <div class="toolbar">
85
+ <h2>My Blobs</h2>
86
+ <button type="button" :disabled="!address || loading" @click="loadBlobs">
87
+ {{ loading ? 'Loading…' : 'Refresh' }}
88
+ </button>
89
+ </div>
90
+
91
+ <p v-if="!address" class="hint">Connect your wallet to list your Walrus blobs.</p>
92
+ <p v-else-if="error" class="err">{{ error }}</p>
93
+ <p v-else-if="!loading && blobs.length === 0" class="hint">No Walrus blobs found for this address.</p>
94
+
95
+ <table v-if="blobs.length" class="blob-table">
96
+ <thead>
97
+ <tr>
98
+ <th>Blob ID</th>
99
+ <th>Size</th>
100
+ <th>Expires</th>
101
+ <th>Certified</th>
102
+ <th>Actions</th>
103
+ </tr>
104
+ </thead>
105
+ <tbody>
106
+ <tr
107
+ v-for="blob in blobs"
108
+ :key="blob.objectId"
109
+ :class="{ warn: blob.endEpoch - currentEpoch < EXPIRY_WARN_EPOCHS }"
110
+ >
111
+ <td class="mono">{{ blob.blobId.slice(0, 12) }}…</td>
112
+ <td>{{ (blob.size / 1024).toFixed(1) }} KB</td>
113
+ <td>
114
+ epoch {{ blob.endEpoch }}
115
+ <span class="approx">(≈{{ epochsToApproxDays(blob.endEpoch - currentEpoch) }})</span>
116
+ </td>
117
+ <td>{{ blob.certified ? '✓' : '—' }}</td>
118
+ <td>
119
+ <span v-if="extendStatus[blob.objectId]" class="ext-status">
120
+ {{ extendStatus[blob.objectId] }}
121
+ </span>
122
+ <button
123
+ v-else
124
+ type="button"
125
+ :disabled="extending === blob.objectId"
126
+ @click="extendBlob(blob)"
127
+ >
128
+ +10 epochs
129
+ </button>
130
+ </td>
131
+ </tr>
132
+ </tbody>
133
+ </table>
134
+ </section>
135
+ </template>
136
+
137
+ <style scoped>
138
+ .my-blobs {
139
+ margin-top: 1.5rem;
140
+ }
141
+ .toolbar {
142
+ display: flex;
143
+ align-items: center;
144
+ gap: 1rem;
145
+ margin-bottom: 0.75rem;
146
+ }
147
+ .toolbar h2 {
148
+ margin: 0;
149
+ font-size: 1.2rem;
150
+ }
151
+ .hint {
152
+ color: var(--mw-color-text-muted, #888);
153
+ font-size: 0.9rem;
154
+ }
155
+ .err {
156
+ color: var(--mw-color-danger, #c00);
157
+ font-size: 0.9rem;
158
+ }
159
+ .blob-table {
160
+ width: 100%;
161
+ border-collapse: collapse;
162
+ font-size: 0.88rem;
163
+ }
164
+ .blob-table th,
165
+ .blob-table td {
166
+ text-align: left;
167
+ padding: 0.4rem 0.6rem;
168
+ border-bottom: 1px solid var(--mw-color-border, #ddd);
169
+ }
170
+ .blob-table th {
171
+ font-weight: 600;
172
+ color: var(--mw-color-text-muted, #888);
173
+ }
174
+ .blob-table tr.warn td {
175
+ background: color-mix(in srgb, var(--mw-color-warning, #f90) 8%, transparent);
176
+ }
177
+ .mono {
178
+ font-family: monospace;
179
+ }
180
+ .approx {
181
+ font-size: 0.8em;
182
+ color: var(--mw-color-text-muted, #888);
183
+ }
184
+ .ext-status {
185
+ font-size: 0.85rem;
186
+ color: var(--mw-color-text-muted, #888);
187
+ }
188
+ </style>
package/src/config.ts ADDED
@@ -0,0 +1,63 @@
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).
4
+ 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'
10
+
11
+ const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env ?? {}
12
+
13
+ /** Active Walrus network, from `VITE_NETWORK` (default `testnet`). */
14
+ export const NETWORK: WalrusNetwork = (env.VITE_NETWORK as WalrusNetwork) || 'testnet'
15
+
16
+ /** Public Mysten relays — the fallback when no operator relay is configured. */
17
+ export const PUBLIC_WALRUS_RELAY_HOSTS: Record<WalrusNetwork, string> = {
18
+ testnet: 'https://upload-relay.testnet.walrus.space',
19
+ mainnet: 'https://upload-relay.mainnet.walrus.space',
20
+ }
21
+
22
+ /** Operator relay host per network (env → public fallback). */
23
+ export const OPERATOR_RELAY_HOSTS: Record<WalrusNetwork, string> = {
24
+ testnet: env.VITE_WALRUS_RELAY_TESTNET || PUBLIC_WALRUS_RELAY_HOSTS.testnet,
25
+ mainnet: env.VITE_WALRUS_RELAY_MAINNET || PUBLIC_WALRUS_RELAY_HOSTS.mainnet,
26
+ }
27
+
28
+ /** The operator/public host pair for `useWalrusRelay`. */
29
+ export function relayHosts(network: WalrusNetwork): { operator: string; public: string } {
30
+ return { operator: OPERATOR_RELAY_HOSTS[network], public: PUBLIC_WALRUS_RELAY_HOSTS[network] }
31
+ }
32
+
33
+ /**
34
+ * JSON-RPC endpoint used to build + execute the register/certify transactions.
35
+ * NOTE: this is JSON-RPC (the public testnet fullnode serves gRPC only for JSON-RPC,
36
+ * so a JSON-RPC-capable endpoint is used for testnet).
37
+ */
38
+ export const RPC_URLS: Record<WalrusNetwork, string> = {
39
+ testnet: env.VITE_RPC_TESTNET || 'https://sui-testnet-rpc.publicnode.com',
40
+ mainnet: env.VITE_RPC_MAINNET || 'https://fullnode.mainnet.sui.io:443',
41
+ }
42
+
43
+ /**
44
+ * Parse the optional NFT access-gate config for a network from env.
45
+ * packageId and platformConfigId are hardcoded in the library — operators only
46
+ * need to supply their Gate object ID, soulbound flag, and purchase price.
47
+ */
48
+ export function accessGate(network: WalrusNetwork): RelayGateConfig | null {
49
+ const NET = network.toUpperCase()
50
+ const packageId = ACCESS_GATE_PACKAGE_ID[network]
51
+ const platformConfigId = ACCESS_GATE_PLATFORM_CONFIG_ID[network]
52
+ const gateId = env[`VITE_ACCESS_GATE_ID_${NET}`]
53
+ if (!packageId || !platformConfigId || !gateId) return null
54
+ const soulbound = env[`VITE_ACCESS_GATE_SOULBOUND_${NET}`] === 'true'
55
+ return {
56
+ packageId,
57
+ gateId,
58
+ platformConfigId,
59
+ nftType: accessGateNftType(network, soulbound),
60
+ soulbound,
61
+ priceMist: BigInt(env[`VITE_ACCESS_GATE_PRICE_MIST_${NET}`] || '0'),
62
+ }
63
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module '*.vue' {
4
+ import type { DefineComponent } from 'vue'
5
+ const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
6
+ export default component
7
+ }
package/src/main.ts ADDED
@@ -0,0 +1,5 @@
1
+ import '@meddleware/design-tokens/tokens.css'
2
+ import { createApp } from 'vue'
3
+ import App from './App.vue'
4
+
5
+ createApp(App).mount('#app')
package/src/wallet.ts ADDED
@@ -0,0 +1,160 @@
1
+ // Minimal Sui wallet integration on @mysten/wallet-standard (dapp-kit is React-only).
2
+ // Discovers wallets, connects, signs personal messages (for the NFT-gate proof), and
3
+ // adapts the connected wallet into a transaction executor. Module singleton.
4
+ //
5
+ // NOTE: the signing path can only be exercised with a real wallet extension in a
6
+ // browser; it is intentionally thin and executes via a JSON-RPC client so we control
7
+ // the returned effects.
8
+ import { markRaw, readonly, ref, shallowRef } from 'vue'
9
+ import { getWallets, isWalletWithRequiredFeatureSet } from '@mysten/wallet-standard'
10
+ import type { Wallet, WalletAccount } from '@mysten/wallet-standard'
11
+ import { SuiJsonRpcClient } from '@mysten/sui/jsonRpc'
12
+ import type { Transaction } from '@mysten/sui/transactions'
13
+ import type { WalrusNetwork } from '@meddleware/walrus-relay'
14
+ import { RPC_URLS } from './config.js'
15
+
16
+ const REQUIRED_FEATURES = ['standard:connect', 'sui:signTransaction'] as const
17
+
18
+ const wallets = shallowRef<Wallet[]>([])
19
+ const currentWallet = shallowRef<Wallet | null>(null)
20
+ const account = shallowRef<WalletAccount | null>(null)
21
+ const connecting = ref(false)
22
+ const error = ref<string | null>(null)
23
+
24
+ const clients = new Map<WalrusNetwork, SuiJsonRpcClient>()
25
+ /** Return a memoised {@link SuiJsonRpcClient} for the network (one instance per network). */
26
+ export function getSuiClient(network: WalrusNetwork): SuiJsonRpcClient {
27
+ let c = clients.get(network)
28
+ if (!c) {
29
+ c = new SuiJsonRpcClient({ url: RPC_URLS[network], network })
30
+ clients.set(network, c)
31
+ }
32
+ return c
33
+ }
34
+
35
+ function refreshWallets(): void {
36
+ // markRaw: extension Wallet objects expose name/icon as ES-private-field getters
37
+ // that throw when accessed through a Vue reactive Proxy.
38
+ wallets.value = getWallets()
39
+ .get()
40
+ .filter((w) => isWalletWithRequiredFeatureSet(w, [...REQUIRED_FEATURES]))
41
+ .map((w) => markRaw(w))
42
+ }
43
+
44
+ let initialised = false
45
+ function init(): void {
46
+ if (initialised) return
47
+ initialised = true
48
+ const api = getWallets()
49
+ refreshWallets()
50
+ api.on('register', refreshWallets)
51
+ api.on('unregister', refreshWallets)
52
+ }
53
+
54
+ async function connect(wallet: Wallet): Promise<void> {
55
+ error.value = null
56
+ connecting.value = true
57
+ try {
58
+ const feature = wallet.features['standard:connect'] as {
59
+ connect: () => Promise<{ accounts: readonly WalletAccount[] }>
60
+ }
61
+ const { accounts } = await feature.connect()
62
+ if (!accounts.length) throw new Error('Wallet returned no accounts.')
63
+ currentWallet.value = markRaw(wallet)
64
+ account.value = markRaw(accounts[0])
65
+ } catch (e) {
66
+ error.value = e instanceof Error ? e.message : String(e)
67
+ throw e
68
+ } finally {
69
+ connecting.value = false
70
+ }
71
+ }
72
+
73
+ function disconnect(): void {
74
+ const disc = currentWallet.value?.features['standard:disconnect'] as
75
+ | { disconnect?: () => Promise<void> }
76
+ | undefined
77
+ void disc?.disconnect?.()
78
+ currentWallet.value = null
79
+ account.value = null
80
+ }
81
+
82
+ /** Sign `nft-gate:access:<nonce>` to prove address control (returns base64 signature). */
83
+ async function signPersonalMessage(message: Uint8Array): Promise<{ signature: string }> {
84
+ const wallet = currentWallet.value
85
+ const acct = account.value
86
+ if (!wallet || !acct) throw new Error('Connect a wallet first.')
87
+ const feature = wallet.features['sui:signPersonalMessage'] as
88
+ | {
89
+ signPersonalMessage: (input: {
90
+ message: Uint8Array
91
+ account: WalletAccount
92
+ }) => Promise<{ bytes: string; signature: string }>
93
+ }
94
+ | undefined
95
+ if (!feature) throw new Error('This wallet cannot sign personal messages.')
96
+ const { signature } = await feature.signPersonalMessage({ message, account: acct })
97
+ return { signature }
98
+ }
99
+
100
+ /** Transaction executor bound to the connected wallet: sign+execute a PTB and await finality. */
101
+ export interface Executor {
102
+ signAndExecute(tx: Transaction): Promise<{ digest: string }>
103
+ waitForTransaction(digest: string): Promise<unknown>
104
+ }
105
+
106
+ /** Build an executor bound to the connected wallet + network. */
107
+ async function buildExecutor(network: WalrusNetwork): Promise<Executor> {
108
+ const wallet = currentWallet.value
109
+ const acct = account.value
110
+ if (!wallet || !acct) throw new Error('Connect a wallet first.')
111
+ const client = getSuiClient(network)
112
+ const chain = `sui:${network}` as const
113
+
114
+ const signFeature = wallet.features['sui:signTransaction'] as {
115
+ signTransaction: (input: {
116
+ transaction: Transaction
117
+ account: WalletAccount
118
+ chain: `sui:${string}`
119
+ }) => Promise<{ bytes: string; signature: string }>
120
+ }
121
+
122
+ return {
123
+ async signAndExecute(tx: Transaction): Promise<{ digest: string }> {
124
+ const { bytes, signature } = await signFeature.signTransaction({
125
+ transaction: tx,
126
+ account: acct,
127
+ chain,
128
+ })
129
+ const res = await client.executeTransactionBlock({
130
+ transactionBlock: bytes,
131
+ signature,
132
+ options: { showEffects: true },
133
+ })
134
+ return { digest: res.digest }
135
+ },
136
+ async waitForTransaction(digest: string): Promise<unknown> {
137
+ return client.waitForTransaction({ digest })
138
+ },
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Wallet composable: discovers wallets, exposes reactive connection state, and provides
144
+ * `connect` / `disconnect` / `signPersonalMessage` / `buildExecutor`. Module singleton —
145
+ * all callers share one wallet/account state.
146
+ */
147
+ export function useWallet() {
148
+ init()
149
+ return {
150
+ wallets: readonly(wallets),
151
+ currentWallet: readonly(currentWallet),
152
+ account: readonly(account),
153
+ connecting: readonly(connecting),
154
+ error: readonly(error),
155
+ connect,
156
+ disconnect,
157
+ signPersonalMessage,
158
+ buildExecutor,
159
+ }
160
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "verbatimModuleSyntax": true,
13
+ "jsx": "preserve",
14
+ "types": ["node"],
15
+ "noEmit": true
16
+ },
17
+ "include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts", "vite.config.ts"]
18
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vite'
2
+ import vue from '@vitejs/plugin-vue'
3
+
4
+ // design-tokens and ui resolve from node_modules (published packages).
5
+ // @mysten/walrus is excluded from dep-optimization so its wasm `?url` import resolves.
6
+ export default defineConfig({
7
+ plugins: [vue()],
8
+ optimizeDeps: {
9
+ exclude: ['@mysten/walrus', '@mysten/walrus-wasm'],
10
+ },
11
+ })