@meddleware/walrus-ui 0.1.16 → 0.1.18
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 +2 -2
- package/src/access-resume.ts +108 -0
- package/src/components/WalrusView.vue +54 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meddleware/walrus-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
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",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"@meddleware/nft-gate-client": "^0.0.6",
|
|
45
45
|
"@meddleware/ui": "^0.1.9",
|
|
46
46
|
"@meddleware/wallet-adapter": "^0.0.5",
|
|
47
|
-
"@meddleware/walrus-client": "^0.0.
|
|
47
|
+
"@meddleware/walrus-client": "^0.0.7",
|
|
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
|
+
}
|
|
@@ -18,6 +18,7 @@ import { useWallet, getSuiClient } from '../wallet.js'
|
|
|
18
18
|
import { fetchChallenge, buildAccessProof } from '@meddleware/nft-gate-client'
|
|
19
19
|
import { NETWORK, relayHosts, accessGate, uploadRelayMaxTipMist } from '../config.js'
|
|
20
20
|
import { runBlobUpload } from '../upload-flow.js'
|
|
21
|
+
import { consumeStorageKey, isRedeemedConflict, resolveGatedAuthToken } from '../access-resume.js'
|
|
21
22
|
import { useOwnedBlobs } from '../composables/useOwnedBlobs.js'
|
|
22
23
|
import MyBlobs from './MyBlobs.vue'
|
|
23
24
|
|
|
@@ -53,34 +54,69 @@ async function onPurchase(): Promise<void> {
|
|
|
53
54
|
// Wire the shared WalrusUpload widget to the extracted upload orchestration + the wallet. The
|
|
54
55
|
// register/upload/certify sequence lives in src/upload-flow.ts (unit-tested); this closure only
|
|
55
56
|
// gathers the wallet-bound inputs (executor, sui client, gated proof token) and delegates.
|
|
57
|
+
//
|
|
58
|
+
// Single-use resume: the relay treats the permanent on-chain `consumeDigest` as the one-time
|
|
59
|
+
// redemption token, so a use is only spent when an upload succeeds. We persist the digest before
|
|
60
|
+
// uploading and reuse it on retry/reload (re-signing a fresh challenge is free) — an interrupted
|
|
61
|
+
// upload never burns a use. On success we clear it; if the relay reports the digest already
|
|
62
|
+
// redeemed (a prior upload actually landed), we clear and consume a fresh use once.
|
|
56
63
|
async function performUpload(
|
|
57
64
|
bytes: Uint8Array,
|
|
58
65
|
opts: { relayHost: string; onStatus: (s: string) => void },
|
|
59
66
|
): Promise<UploadResult> {
|
|
60
67
|
if (!account.value) throw new Error('Connect your wallet first.')
|
|
61
68
|
const executor = await buildExecutor()
|
|
69
|
+
const address = account.value.address
|
|
62
70
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
// Ungated relay: no consume, straight upload.
|
|
72
|
+
if (!(gate && gateState.hasAccess.value === true && gateState.nftId.value)) {
|
|
73
|
+
return runBlobUpload(uploadDeps(bytes, opts, executor, undefined))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const nftId = gateState.nftId.value
|
|
77
|
+
const key = consumeStorageKey(NETWORK, gate.gateId, address)
|
|
78
|
+
const resolve = (forceFresh: boolean) =>
|
|
79
|
+
resolveGatedAuthToken({
|
|
80
|
+
storage: window.localStorage,
|
|
81
|
+
key,
|
|
82
|
+
relayHost: opts.relayHost,
|
|
83
|
+
address,
|
|
84
|
+
nftId,
|
|
85
|
+
fetchChallenge,
|
|
86
|
+
buildConsume: (id, nonce) => gateState.buildConsume(id, nonce),
|
|
87
|
+
signAndExecute: (tx) => executor.signAndExecute(tx),
|
|
88
|
+
waitForTransaction: (digest) => executor.waitForTransaction(digest),
|
|
89
|
+
buildAccessProof,
|
|
78
90
|
sign: signPersonalMessage,
|
|
79
|
-
|
|
91
|
+
forceFresh,
|
|
80
92
|
})
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const authToken = await resolve(false)
|
|
96
|
+
const result = await runBlobUpload(uploadDeps(bytes, opts, executor, authToken))
|
|
97
|
+
window.localStorage.removeItem(key) // upload succeeded → the use is now spent
|
|
98
|
+
return result
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (!isRedeemedConflict(e)) throw e // transient failure → keep the digest so the next try resumes
|
|
101
|
+
// The stored consume was already redeemed (a prior upload actually landed). Clear it and spend
|
|
102
|
+
// one fresh use for this new upload.
|
|
103
|
+
window.localStorage.removeItem(key)
|
|
104
|
+
const authToken = await resolve(true)
|
|
105
|
+
const result = await runBlobUpload(uploadDeps(bytes, opts, executor, authToken))
|
|
106
|
+
window.localStorage.removeItem(key)
|
|
107
|
+
return result
|
|
81
108
|
}
|
|
109
|
+
}
|
|
82
110
|
|
|
83
|
-
|
|
111
|
+
/** Assemble the register→upload→certify inputs for {@link runBlobUpload}. */
|
|
112
|
+
function uploadDeps(
|
|
113
|
+
bytes: Uint8Array,
|
|
114
|
+
opts: { relayHost: string; onStatus: (s: string) => void },
|
|
115
|
+
executor: Awaited<ReturnType<typeof buildExecutor>>,
|
|
116
|
+
authToken: string | undefined,
|
|
117
|
+
) {
|
|
118
|
+
if (!account.value) throw new Error('Connect your wallet first.')
|
|
119
|
+
return {
|
|
84
120
|
bytes,
|
|
85
121
|
network: NETWORK,
|
|
86
122
|
relayHost: opts.relayHost,
|
|
@@ -92,7 +128,7 @@ async function performUpload(
|
|
|
92
128
|
suiClient: getSuiClient(),
|
|
93
129
|
authToken,
|
|
94
130
|
onStatus: opts.onStatus,
|
|
95
|
-
}
|
|
131
|
+
}
|
|
96
132
|
}
|
|
97
133
|
|
|
98
134
|
const ownedBlobs = useOwnedBlobs()
|