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