@meddleware/walrus-ui 0.1.42 → 0.1.43
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 +4 -5
- package/package.json +7 -7
- package/src/App.vue +4 -4
- package/src/components/MyBlobs.vue +18 -11
- package/src/components/WalrusView.vue +91 -104
- package/src/composables/useOwnedBlobs.ts +11 -7
- package/src/config.ts +34 -36
- package/src/main.ts +7 -0
- package/src/access-resume.ts +0 -114
- package/src/certify-resume.ts +0 -66
- package/src/upload-flow.ts +0 -179
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
|
-
`
|
|
46
|
-
`@meddleware/walrus-relay` — operators only configure the values listed
|
|
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.
|
|
3
|
+
"version": "0.1.43",
|
|
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,18 @@
|
|
|
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.
|
|
52
|
-
"@meddleware/walrus-client": "^0.0.
|
|
53
|
-
"@meddleware/walrus-relay": "^0.1.
|
|
50
|
+
"@meddleware/wallet-adapter": "^0.0.12",
|
|
51
|
+
"@meddleware/walrus-client": "^0.0.20",
|
|
52
|
+
"@meddleware/walrus-relay": "^0.1.20",
|
|
54
53
|
"@mysten/sui": "^2.33.1",
|
|
55
54
|
"@mysten/wallet-standard": "^0.21.0",
|
|
56
|
-
"@mysten/walrus": "~1.2.
|
|
55
|
+
"@mysten/walrus": "~1.2.32",
|
|
57
56
|
"@mysten/walrus-wasm": "~0.3.1",
|
|
58
57
|
"vue": "^3.5.43"
|
|
59
58
|
},
|
|
60
59
|
"devDependencies": {
|
|
60
|
+
"@meddleware/access-gate-client": "^0.0.1",
|
|
61
61
|
"@playwright/test": "~1.63.0",
|
|
62
62
|
"@types/node": "~24.12.2",
|
|
63
63
|
"@vitejs/plugin-vue": "^6.0.8",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"stylelint-config-standard": "^40.0.0",
|
|
76
76
|
"typescript": "~6.0.0",
|
|
77
77
|
"vite": "^8.3.0",
|
|
78
|
-
"vitest": "~5.0.
|
|
78
|
+
"vitest": "~5.0.2",
|
|
79
79
|
"vue-eslint-parser": "^10.4.1",
|
|
80
80
|
"vue-tsc": "~3.3.0"
|
|
81
81
|
},
|
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 {
|
|
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[
|
|
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
|
-
<
|
|
26
|
+
<main class="app__content">
|
|
27
27
|
<WalrusView />
|
|
28
|
-
</
|
|
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 {
|
|
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 '
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
50
|
-
//
|
|
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) => {
|
|
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
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
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
|
|
92
|
-
|
|
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:
|
|
107
|
+
key: consumeStorageKey(net, gate.gateId, address),
|
|
105
108
|
relayHost: opts.relayHost,
|
|
106
109
|
address,
|
|
107
|
-
nftId
|
|
108
|
-
|
|
109
|
-
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
|
-
|
|
116
|
+
onStatus: opts.onStatus,
|
|
115
117
|
})
|
|
118
|
+
: undefined
|
|
116
119
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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:
|
|
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 ({{
|
|
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
|
-
|
|
238
|
-
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
-
</
|
|
321
|
-
</
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
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
|
-
/**
|
|
18
|
-
export const
|
|
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
|
-
/**
|
|
46
|
-
|
|
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
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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 (!
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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')
|
package/src/access-resume.ts
DELETED
|
@@ -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
|
-
}
|
package/src/certify-resume.ts
DELETED
|
@@ -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
|
-
}
|
package/src/upload-flow.ts
DELETED
|
@@ -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
|
-
}
|