@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meddleware/walrus-ui",
3
- "version": "0.1.16",
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.6",
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
- // If the relay is NFT-gated and we hold access, run the SINGLE_USE consume flow:
64
- // fetch a challenge nonce, execute the on-chain access_gate::consume tx to record
65
- // use of that nonce (produces a consumeDigest), then sign a proof token that
66
- // includes both. SINGLE_USE=true gateways reject proofs without a consumeDigest.
67
- // TODO: replace this block with `gateState.consumeAndBuildToken(...)` once
68
- // @meddleware/walrus-relay 0.1.6 is installed.
69
- let authToken: string | undefined
70
- if (gate && gateState.hasAccess.value === true && gateState.nftId.value) {
71
- const challenge = await fetchChallenge(opts.relayHost)
72
- const consumeTx = gateState.buildConsume(gateState.nftId.value, challenge.nonce)
73
- const consumeResult = await executor.signAndExecute(consumeTx)
74
- if (consumeResult.digest) await executor.waitForTransaction(consumeResult.digest).catch(() => {})
75
- authToken = await buildAccessProof({
76
- address: account.value.address,
77
- challenge,
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
- consumeDigest: consumeResult.digest,
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
- return runBlobUpload({
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()