@meddleware/nft-gate-gateway 0.0.6 → 0.0.8

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/nft-gate-gateway",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
5
  "description": "Cloudflare Workers implementation of the nft-gate gateway: a wire-identical, drop-in sibling of the Rust gateway that gates any HTTP upstream behind an access_gate NFT.",
6
6
  "license": "0BSD",
@@ -22,6 +22,7 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@meddleware/nft-gate-client": "^0.0.6",
25
+ "@mysten/sui": "~2.30.0",
25
26
  "@noble/curves": "~2.4.0",
26
27
  "@noble/hashes": "~2.4.0"
27
28
  },
package/src/chain.ts CHANGED
@@ -1,10 +1,20 @@
1
1
  /**
2
- * Production {@link ChainQuery} over Sui JSON-RPC (`suix_getOwnedObjects`, `suix_queryEvents`,
3
- * `sui_getTransactionBlock`) via `fetch`. A 1:1 port of the Rust gateway's `sui_rpc.rs`,
4
- * including the identical request JSON shapes. The pure match/parse helpers are exported for
5
- * unit tests; the RPC round-trips are covered by the localnet/integration loop.
2
+ * Production {@link ChainQuery} over the Sui **gRPC** API (`@mysten/sui/grpc` `SuiGrpcClient`).
3
+ *
4
+ * Public Sui fullnodes have deprecated JSON-RPC (`suix_queryEvents`, `sui_getTransactionBlock`,
5
+ * `suix_getOwnedObjects` now return `-32601 Method not found`), so the gateway queries the chain
6
+ * over gRPC — the same transport `@meddleware/walrus-client` and `@meddleware/nft-gate-client`
7
+ * already use. The pure match/parse helpers are exported for unit tests; the gRPC round-trips are
8
+ * covered by the localnet/integration loop.
9
+ *
10
+ * Single-use verification is now **digest-first**: the access proof already carries the on-chain
11
+ * `access_gate::consume` transaction digest, so the gateway fetches that exact transaction and
12
+ * verifies it succeeded and emitted a matching `AccessConsumedEvent`. This is precise (bound to the
13
+ * challenge nonce + sender + gate) and does not need the deprecated event-by-sender query.
6
14
  */
7
15
 
16
+ import { SuiGrpcClient } from '@mysten/sui/grpc'
17
+ import { fetchAccessNfts } from '@meddleware/nft-gate-client'
8
18
  import type { ChainQuery } from './verify.js'
9
19
  import { base64ToBytes } from './crypto.js'
10
20
 
@@ -20,44 +30,37 @@ interface CacheEntry {
20
30
  expiry: number
21
31
  }
22
32
 
23
- /** Production {@link ChainQuery} backed by Sui JSON-RPC, with an optional ownership cache. */
24
- export class SuiRpc implements ChainQuery {
33
+ /** Number of `getTransaction` attempts (absorbs fullnode indexing lag after the client's finality wait). */
34
+ const TX_FETCH_ATTEMPTS = 4
35
+ /** Delay between `getTransaction` retries, in ms. */
36
+ const TX_FETCH_RETRY_MS = 500
37
+
38
+ /** Production {@link ChainQuery} backed by the Sui gRPC API, with an optional ownership cache. */
39
+ export class SuiGrpc implements ChainQuery {
25
40
  private readonly cache = new Map<string, CacheEntry>()
41
+ private readonly client: SuiGrpcClient
26
42
 
27
43
  /**
28
- * @param rpcUrl - Sui JSON-RPC endpoint URL.
44
+ * @param rpcUrl - Sui gRPC endpoint base URL (e.g. `https://fullnode.testnet.sui.io:443`).
29
45
  * @param cacheTtlMs - Ownership-cache TTL in ms. 0 disables the cache (live check every request).
30
- * @param authHeader - Optional header injected on every RPC call (e.g. credentialed fullnode auth).
46
+ * @param authHeader - Optional header injected on every gRPC call (e.g. credentialed fullnode auth).
31
47
  */
32
48
  constructor(
33
49
  private readonly rpcUrl: string,
34
50
  /** Ownership-cache TTL (ms). 0 = disabled (every gated check is live on-chain). */
35
51
  private readonly cacheTtlMs: number = 0,
36
- private readonly authHeader?: { name: string; value: string },
37
- ) {}
38
-
39
- /**
40
- * Send a Sui JSON-RPC 2.0 request and return the `result` field.
41
- *
42
- * @param method - JSON-RPC method name (e.g. `suix_getOwnedObjects`).
43
- * @param params - Positional parameters array.
44
- * @returns The `result` value from the RPC response, or `null`.
45
- * @throws If the HTTP response is not OK or the RPC body contains an `error` field.
46
- */
47
- private async call(method: string, params: Json): Promise<Json> {
48
- const headers: Record<string, string> = { 'content-type': 'application/json' }
49
- if (this.authHeader) headers[this.authHeader.name] = this.authHeader.value
50
- const resp = await fetch(this.rpcUrl, {
51
- method: 'POST',
52
- headers,
53
- body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
52
+ authHeader?: { name: string; value: string },
53
+ ) {
54
+ // The `network` label is cosmetic when an explicit `baseUrl` is supplied (core RPC resolves via
55
+ // the endpoint, not the label); infer it from the URL so a mainnet fullnode is labelled correctly.
56
+ const network = /mainnet/i.test(rpcUrl) ? 'mainnet' : 'testnet'
57
+ this.client = new SuiGrpcClient({
58
+ network,
59
+ baseUrl: rpcUrl,
60
+ // gRPC-web metadata keys must be lower-case ASCII; a credentialed fullnode auth header
61
+ // (e.g. `Authorization: Bearer …`) is threaded here on every call.
62
+ ...(authHeader ? { meta: { [authHeader.name.toLowerCase()]: authHeader.value } } : {}),
54
63
  })
55
- if (!resp.ok) throw new Error(`rpc http ${resp.status} from ${method}`)
56
- const json = (await resp.json()) as Record<string, Json>
57
- if (json && typeof json === 'object' && 'error' in json && json.error) {
58
- throw new Error(`rpc error from ${method}: ${JSON.stringify(json.error)}`)
59
- }
60
- return (json as { result?: Json }).result ?? null
61
64
  }
62
65
 
63
66
  /**
@@ -84,7 +87,9 @@ export class SuiRpc implements ChainQuery {
84
87
  }
85
88
 
86
89
  /**
87
- * Uncached, live `suix_getOwnedObjects` ownership query.
90
+ * Uncached, live ownership query over gRPC `listOwnedObjects`. Reuses `fetchAccessNfts` from
91
+ * `@meddleware/nft-gate-client` (the same gRPC core-API parse the frontend uses), so the
92
+ * gateway and client agree on what counts as a held access NFT.
88
93
  *
89
94
  * @param address - Sui address to query.
90
95
  * @param nftType - NFT struct type to filter by.
@@ -92,16 +97,8 @@ export class SuiRpc implements ChainQuery {
92
97
  * @returns `true` if at least one qualifying NFT is owned.
93
98
  */
94
99
  private async ownsNftLive(address: string, nftType: string, gateId?: string): Promise<boolean> {
95
- const params = [
96
- address,
97
- { filter: { StructType: nftType }, options: { showContent: true } },
98
- null,
99
- 50,
100
- ]
101
- const result = (await this.call('suix_getOwnedObjects', params)) as { data?: Json[] } | null
102
- const data = (result && Array.isArray(result.data) ? result.data : []) as Json[]
103
- if (gateId === undefined) return data.length > 0
104
- return data.some((entry) => pointerStr(entry, ['data', 'content', 'fields', 'data', 'fields', 'gate_id']) === gateId)
100
+ const nfts = await fetchAccessNfts(this.client, address, nftType, gateId)
101
+ return nfts.length > 0
105
102
  }
106
103
 
107
104
  /**
@@ -116,7 +113,7 @@ export class SuiRpc implements ChainQuery {
116
113
  async ownsNft(address: string, nftType: string, gateId?: string): Promise<boolean> {
117
114
  // Cache is OFF by default (ttl 0) so a gated action is confirmed live on-chain.
118
115
  if (this.cacheTtlMs > 0) {
119
- const key = SuiRpc.cacheKey(address, nftType, gateId)
116
+ const key = SuiGrpc.cacheKey(address, nftType, gateId)
120
117
  const hit = this.cache.get(key)
121
118
  const now = Date.now()
122
119
  if (hit && hit.expiry > now) return hit.owns
@@ -128,70 +125,64 @@ export class SuiRpc implements ChainQuery {
128
125
  }
129
126
 
130
127
  /**
131
- * Directly verify the consume transaction named by `digest` via `sui_getTransactionBlock`.
132
- * The tx must have succeeded and emitted a matching `AccessConsumedEvent`. Defence-in-depth
133
- * atop the sender+nonce event query.
128
+ * Fetch a transaction by digest via gRPC, retrying briefly to absorb the window between the
129
+ * client's finality wait and the gateway fullnode indexing the transaction.
134
130
  *
135
- * @param digest - Transaction digest of the on-chain consume.
136
- * @param nonce - The challenge nonce that was consumed.
137
- * @param address - Expected transaction sender.
138
- * @param gateId - Optional gate object ID constraint.
139
- * @returns `true` if the transaction confirms the consume.
131
+ * @param digest - Transaction digest to fetch.
132
+ * @returns The gRPC `TransactionResult` (`$kind: 'Transaction' | 'FailedTransaction'`).
133
+ * @throws If every attempt fails (surfaces as a {@link ChainQuery} ChainError → 502).
140
134
  */
141
- private async consumeTxMatches(
142
- digest: string,
143
- nonce: string,
144
- address: string,
145
- gateId?: string,
146
- ): Promise<boolean> {
147
- const params = [digest, { showEvents: true, showEffects: true }]
148
- const result = (await this.call('sui_getTransactionBlock', params)) as Json
149
- const status = pointerStr(result, ['effects', 'status', 'status'])
150
- if (status !== 'success') return false
151
- const events = (pointer(result, ['events']) as Json[] | undefined) ?? []
152
- return (
153
- Array.isArray(events) &&
154
- events.some((ev) => isConsumedEvent(ev) && eventMatches(ev, nonce, address, gateId))
155
- )
135
+ private async getTransaction(digest: string): Promise<Json> {
136
+ let lastErr: unknown
137
+ for (let attempt = 0; attempt < TX_FETCH_ATTEMPTS; attempt++) {
138
+ try {
139
+ return (await this.client.core.getTransaction({
140
+ digest,
141
+ include: { events: true },
142
+ })) as Json
143
+ } catch (e) {
144
+ lastErr = e
145
+ if (attempt < TX_FETCH_ATTEMPTS - 1) {
146
+ await new Promise<void>((r) => setTimeout(r, TX_FETCH_RETRY_MS))
147
+ }
148
+ }
149
+ }
150
+ throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
156
151
  }
157
152
 
158
153
  /**
159
- * Verify a single-use consume via `suix_queryEvents`. Queries for an `AccessConsumedEvent`
160
- * emitted by `address` carrying `nonce`; if `consumeDigest` is also provided, additionally
161
- * verifies that exact transaction (F4: defence in depth).
154
+ * Verify a single-use consume by fetching its `consumeDigest` transaction directly and confirming
155
+ * it succeeded and emitted an `AccessConsumedEvent` for this sender + gate.
156
+ *
157
+ * The event is bound to the sender (which must equal the signature-verified proof address) and
158
+ * the gate — NOT to the challenge nonce. Decoupling from the nonce is what lets an interrupted
159
+ * upload resume with a fresh (free) challenge signature while reusing the same on-chain consume;
160
+ * single-use is then enforced by the gateway's redemption store keying on this `consumeDigest`.
161
+ * An attacker cannot present someone else's consume (the event `sender` would not match the
162
+ * signed address) nor forge one without owning the soulbound NFT.
162
163
  *
163
- * @param nonce - The challenge nonce that was consumed.
164
- * @param address - The Sui address that submitted the consume transaction.
165
- * @param nftType - Fully-qualified NFT type used to derive the event package.
164
+ * @param consumeDigest - Transaction digest of the on-chain consume.
165
+ * @param address - The signature-verified proof address; must equal the event sender.
166
166
  * @param gateId - Optional gate object ID constraint.
167
- * @param consumeDigest - Optional transaction digest for direct tx verification.
168
- * @returns `true` if a matching consume event (and, when given, a matching tx) is found.
167
+ * @returns `true` if the transaction confirms a matching consume.
169
168
  */
170
- async consumeEventMatches(
171
- nonce: string,
172
- address: string,
173
- nftType: string,
174
- gateId?: string,
175
- consumeDigest?: string,
176
- ): Promise<boolean> {
177
- const pkg = SuiRpc.packageOf(nftType)
178
- if (!pkg) throw new Error('cannot derive package from nft_type')
179
- const eventType = `${pkg}::access_gate::AccessConsumedEvent`
180
- // Filter by BOTH the event type AND the emitting Sender (F5): O(this user's events).
181
- const params = [{ All: [{ MoveEventType: eventType }, { Sender: address }] }, null, 50, true]
182
- const result = (await this.call('suix_queryEvents', params)) as { data?: Json[] } | null
183
- const data = (result && Array.isArray(result.data) ? result.data : []) as Json[]
184
- const primary = data.some((ev) => eventMatches(ev, nonce, address, gateId))
185
- if (!primary) return false
186
- // F4: if a consume tx digest was supplied, verify that exact transaction too.
187
- if (consumeDigest !== undefined) {
188
- return this.consumeTxMatches(consumeDigest, nonce, address, gateId)
189
- }
190
- return true
169
+ async consumeTxValid(consumeDigest: string, address: string, gateId?: string): Promise<boolean> {
170
+ const res = (await this.getTransaction(consumeDigest)) as Record<string, Json> | null
171
+ // The gRPC result is a oneof: `{ $kind: 'Transaction', Transaction }` on success, or
172
+ // `{ $kind: 'FailedTransaction', FailedTransaction }` when the transaction aborted.
173
+ if (!res || res.$kind !== 'Transaction') return false
174
+ const tx = res.Transaction as Record<string, Json> | undefined
175
+ const status = tx?.status as { success?: boolean } | undefined
176
+ if (!status?.success) return false
177
+ const events = (Array.isArray(tx?.events) ? (tx?.events as Json[]) : []) as Json[]
178
+ return events.some((ev) => isConsumedEvent(ev) && eventMatches(ev, address, gateId))
191
179
  }
192
180
  }
193
181
 
194
- // ── pure helpers (unit-tested; mirror sui_rpc.rs) ────────────────────────────
182
+ // ── pure helpers (unit-tested; adapted to the gRPC event shape) ───────────────
183
+ // gRPC events expose `eventType`/`sender`/`json`; JSON-RPC used `type`/`sender`/`parsedJson`.
184
+ // The helpers read both keys so they stay tolerant to transport/shape variation (the SDK documents
185
+ // that the `json` shape may differ between transports).
195
186
 
196
187
  /**
197
188
  * Traverse a nested JSON value by a sequence of object keys.
@@ -224,14 +215,24 @@ function pointerStr(v: Json, path: string[]): string | undefined {
224
215
  return typeof r === 'string' ? r : undefined
225
216
  }
226
217
 
218
+ /** The event's Move type string, from the gRPC (`eventType`) or JSON-RPC (`type`) shape. */
219
+ function eventType(ev: Json): string | undefined {
220
+ return pointerStr(ev, ['eventType']) ?? pointerStr(ev, ['type'])
221
+ }
222
+
223
+ /** The event's parsed Move struct fields, from the gRPC (`json`) or JSON-RPC (`parsedJson`) shape. */
224
+ function eventFields(ev: Json): Json {
225
+ return pointer(ev, ['json']) ?? pointer(ev, ['parsedJson'])
226
+ }
227
+
227
228
  /** True if the event's type ends with `::access_gate::AccessConsumedEvent`. */
228
229
  export function isConsumedEvent(ev: Json): boolean {
229
- const t = pointerStr(ev, ['type'])
230
+ const t = eventType(ev)
230
231
  return t !== undefined && t.endsWith('::access_gate::AccessConsumedEvent')
231
232
  }
232
233
 
233
234
  /**
234
- * Match the on-chain `AccessConsumedEvent.nonce` (`vector<u8>`), rendered by RPC as either an
235
+ * Match the on-chain `AccessConsumedEvent.nonce` (`vector<u8>`), rendered by the API as either an
235
236
  * array of byte numbers or a base64 string, against the challenge nonce's UTF-8 bytes.
236
237
  */
237
238
  export function nonceMatches(eventNonce: Json, nonce: string): boolean {
@@ -251,11 +252,14 @@ export function nonceMatches(eventNonce: Json, nonce: string): boolean {
251
252
  return false
252
253
  }
253
254
 
254
- /** sender == address, nonce matches, and (if given) gate_id matches. */
255
- export function eventMatches(ev: Json, nonce: string, address: string, gateId?: string): boolean {
255
+ /**
256
+ * `sender == address` and (if given) `gate_id` matches. The consume event is bound to the
257
+ * signature-verified sender + gate; single-use is enforced separately by redemption tracking on the
258
+ * `consumeDigest`, so the event nonce is intentionally not checked here (see `consumeTxValid`).
259
+ */
260
+ export function eventMatches(ev: Json, address: string, gateId?: string): boolean {
256
261
  const senderOk = pointerStr(ev, ['sender']) === address
257
- const nonceVal = pointer(ev, ['parsedJson', 'nonce'])
258
- const nonceOk = nonceVal !== undefined && nonceMatches(nonceVal, nonce)
259
- const gateOk = gateId === undefined ? true : pointerStr(ev, ['parsedJson', 'gate_id']) === gateId
260
- return senderOk && nonceOk && gateOk
262
+ const fields = eventFields(ev)
263
+ const gateOk = gateId === undefined ? true : pointerStr(fields, ['gate_id']) === gateId
264
+ return senderOk && gateOk
261
265
  }
package/src/config.ts CHANGED
@@ -22,6 +22,10 @@ export interface Env {
22
22
  MAX_BODY_BYTES?: string
23
23
  CHALLENGE_TTL_SECS?: string
24
24
  OWNERSHIP_CACHE_TTL_MS?: string
25
+ /** Single-use: seconds a consume-digest redemption lease is held during an in-flight upload. */
26
+ REDEMPTION_LEASE_TTL_SECS?: string
27
+ /** Single-use: seconds a committed (spent) consume-digest is remembered to block re-redemption. */
28
+ REDEMPTION_RETENTION_SECS?: string
25
29
  // ── Workers-specific ──────────────────────────────────────────────────────
26
30
  /** `durable-object` (default) | `kv`. */
27
31
  NONCE_BACKEND?: string
@@ -71,6 +75,10 @@ export interface Config {
71
75
  rateLimitPerMin: number
72
76
  maxBodyBytes: number
73
77
  ownershipCacheTtlMs: number
78
+ /** Single-use: lease TTL (s) for an in-flight consume-digest redemption. */
79
+ redemptionLeaseTtlSecs: number
80
+ /** Single-use: retention (s) of a committed (spent) consume-digest. */
81
+ redemptionRetentionSecs: number
74
82
  nonceBackend: NonceBackendKind
75
83
  nonceShard: NonceShardMode
76
84
  nonceMaxEntries: number
@@ -135,6 +143,8 @@ export function loadConfig(env: Env): Config {
135
143
  rateLimitPerMin: numOr(env.RATE_LIMIT_PER_MIN, 30),
136
144
  maxBodyBytes: numOr(env.MAX_BODY_BYTES, 262144),
137
145
  ownershipCacheTtlMs: numOr(env.OWNERSHIP_CACHE_TTL_MS, 0),
146
+ redemptionLeaseTtlSecs: numOr(env.REDEMPTION_LEASE_TTL_SECS, 120),
147
+ redemptionRetentionSecs: numOr(env.REDEMPTION_RETENTION_SECS, 2592000),
138
148
  nonceBackend,
139
149
  nonceShard,
140
150
  nonceMaxEntries: numOr(env.NONCE_MAX_ENTRIES, 1000000),
package/src/index.ts CHANGED
@@ -11,7 +11,7 @@ import type { Config, Env } from './config.js'
11
11
  import { loadConfig, isPublicPath } from './config.js'
12
12
  import type { NonceBackend } from './state/types.js'
13
13
  import { makeBackend } from './state/select.js'
14
- import { SuiRpc } from './chain.js'
14
+ import { SuiGrpc } from './chain.js'
15
15
  import { verifyAccessRequest, deniedReason } from './verify.js'
16
16
  import { forward } from './proxy.js'
17
17
  import { runQuotaGuard } from './quota.js'
@@ -26,7 +26,7 @@ export { NonceRateState } from './state/durable_object.js'
26
26
  interface GatewayState {
27
27
  cfg: Config
28
28
  backend: NonceBackend
29
- chain: SuiRpc
29
+ chain: SuiGrpc
30
30
  }
31
31
 
32
32
  /** Lazily initialised once per isolate; `null` before the first request. */
@@ -56,7 +56,7 @@ async function getState(env: Env): Promise<GatewayState> {
56
56
  cached = {
57
57
  cfg,
58
58
  backend: makeBackend(effective, env),
59
- chain: new SuiRpc(cfg.suiRpcUrl, cfg.ownershipCacheTtlMs, cfg.suiRpcAuthHeader),
59
+ chain: new SuiGrpc(cfg.suiRpcUrl, cfg.ownershipCacheTtlMs, cfg.suiRpcAuthHeader),
60
60
  }
61
61
  return cached
62
62
  }
@@ -76,14 +76,16 @@ function json(status: number, body: unknown): Response {
76
76
  }
77
77
 
78
78
  /**
79
- * Build a JSON `{"error": reason}` response with the given status code.
79
+ * Build a JSON `{"error": reason}` response with the given status code, optionally with a stable
80
+ * machine-readable `code` (e.g. `"redeemed"` vs `"leased"`) so the client can react precisely.
80
81
  *
81
82
  * @param status - HTTP status code.
82
83
  * @param reason - Short, client-visible error description.
84
+ * @param code - Optional stable code for programmatic handling.
83
85
  * @returns A JSON error response.
84
86
  */
85
- function deny(status: number, reason: string): Response {
86
- return json(status, { error: reason })
87
+ function deny(status: number, reason: string, code?: string): Response {
88
+ return json(status, code ? { error: reason, code } : { error: reason })
87
89
  }
88
90
 
89
91
  /** Prefer `Authorization: Bearer <token>`; fall back to an explicit `X-Access-Proof` header. */
@@ -160,6 +162,34 @@ async function handle(request: Request, env: Env): Promise<Response> {
160
162
  return deny(429, 'rate limit exceeded')
161
163
  }
162
164
 
165
+ // Single-use: the permanent on-chain `consumeDigest` is the one-time redemption token. Lease it,
166
+ // proxy, then COMMIT on a successful upload or RELEASE on failure — so an interrupted upload
167
+ // leaves the consume redeemable (the use is never lost) while a duplicate can't double-spend it.
168
+ const redemptionKey = result.redemptionKey
169
+ if (redemptionKey !== undefined) {
170
+ const lease = await backend.tryLeaseRedemption(redemptionKey, cfg.redemptionLeaseTtlSecs)
171
+ if (lease === 'redeemed') {
172
+ return deny(409, 'this consume has already been redeemed for an upload', 'redeemed')
173
+ }
174
+ if (lease === 'leased') {
175
+ return deny(409, 'an upload for this consume is already in progress', 'leased')
176
+ }
177
+ let resp: Response
178
+ try {
179
+ resp = await forward(cfg, request)
180
+ } catch (e) {
181
+ // Network/exception before a definitive upstream result — release so the user can retry.
182
+ await backend.releaseRedemption(redemptionKey)
183
+ throw e
184
+ }
185
+ if (resp.ok) {
186
+ await backend.commitRedemption(redemptionKey, cfg.redemptionRetentionSecs)
187
+ } else {
188
+ await backend.releaseRedemption(redemptionKey)
189
+ }
190
+ return resp
191
+ }
192
+
163
193
  return forward(cfg, request)
164
194
  }
165
195
 
@@ -11,9 +11,12 @@
11
11
  */
12
12
 
13
13
  import { DurableObject } from 'cloudflare:workers'
14
- import type { NonceBackend } from './types.js'
14
+ import type { LeaseResult, NonceBackend } from './types.js'
15
15
  import { randomHex24, shardOfNonce } from './types.js'
16
16
 
17
+ /** Fixed shard name for the redemption store (the `consumeDigest` carries no region tag). */
18
+ const REDEEM_SHARD = 'redeem'
19
+
17
20
  /** SQLite row shape for the `nonces` table. */
18
21
  interface NonceRow {
19
22
  expiry: number
@@ -27,6 +30,11 @@ interface RateRow {
27
30
  interface CountRow {
28
31
  c: number
29
32
  }
33
+ /** SQLite row shape for the `redemptions` table. */
34
+ interface RedemptionRow {
35
+ state: string
36
+ expiry: number
37
+ }
30
38
 
31
39
  /**
32
40
  * Durable Object that provides the SQLite-backed nonce store and per-address rate limiter.
@@ -49,6 +57,12 @@ export class NonceRateState extends DurableObject {
49
57
  this.sql.exec(
50
58
  'CREATE TABLE IF NOT EXISTS rate (addr TEXT PRIMARY KEY, start INTEGER NOT NULL, count INTEGER NOT NULL)',
51
59
  )
60
+ // Redemption store: `state` is 'leased' (an in-flight upload holds the consume) or 'committed'
61
+ // (the use was spent on a successful upload). `expiry` is the lease deadline / committed
62
+ // retention deadline (unix ms).
63
+ this.sql.exec(
64
+ 'CREATE TABLE IF NOT EXISTS redemptions (key TEXT PRIMARY KEY, state TEXT NOT NULL, expiry INTEGER NOT NULL)',
65
+ )
52
66
  }
53
67
 
54
68
  /** Store a fresh nonce with a hard entry cap (evict soonest-to-expire). Returns expiry ms. */
@@ -95,6 +109,54 @@ export class NonceRateState extends DurableObject {
95
109
  this.sql.exec('INSERT OR REPLACE INTO rate (addr, start, count) VALUES (?, ?, ?)', addr, start, count + 1)
96
110
  return true
97
111
  }
112
+
113
+ /**
114
+ * Atomically claim `key` for an in-flight upload (single-threaded DO ⇒ no race). Returns
115
+ * `'redeemed'` if already committed, `'leased'` if an unexpired lease is held, else `'ok'`
116
+ * after taking a fresh lease. An expired lease (crashed request) is reclaimable as `'ok'`.
117
+ */
118
+ tryLeaseRedemption(key: string, leaseTtlSecs: number, maxEntries: number): LeaseResult {
119
+ const now = Date.now()
120
+ // Bounded growth: drop expired leases and lapsed committed rows before inserting.
121
+ this.sql.exec("DELETE FROM redemptions WHERE state = 'leased' AND expiry <= ?", now)
122
+ this.sql.exec("DELETE FROM redemptions WHERE state = 'committed' AND expiry <= ?", now)
123
+ const rows = this.sql
124
+ .exec('SELECT state, expiry FROM redemptions WHERE key = ?', key)
125
+ .toArray() as unknown as RedemptionRow[]
126
+ if (rows.length > 0) {
127
+ const r = rows[0]
128
+ if (r.state === 'committed') return 'redeemed'
129
+ if (r.state === 'leased' && Number(r.expiry) > now) return 'leased'
130
+ // else: an expired lease — fall through and re-lease.
131
+ }
132
+ const count = (this.sql.exec('SELECT COUNT(*) AS c FROM redemptions').one() as unknown as CountRow).c
133
+ if (count >= Math.max(1, maxEntries)) {
134
+ // Evict the soonest-to-expire leased row (never a committed one — that would allow reuse).
135
+ this.sql.exec(
136
+ "DELETE FROM redemptions WHERE key = (SELECT key FROM redemptions WHERE state = 'leased' ORDER BY expiry ASC LIMIT 1)",
137
+ )
138
+ }
139
+ this.sql.exec(
140
+ "INSERT OR REPLACE INTO redemptions (key, state, expiry) VALUES (?, 'leased', ?)",
141
+ key,
142
+ now + leaseTtlSecs * 1000,
143
+ )
144
+ return 'ok'
145
+ }
146
+
147
+ /** Permanently mark `key` redeemed (retained `retentionSecs`) — the use is spent. */
148
+ commitRedemption(key: string, retentionSecs: number): void {
149
+ this.sql.exec(
150
+ "INSERT OR REPLACE INTO redemptions (key, state, expiry) VALUES (?, 'committed', ?)",
151
+ key,
152
+ Date.now() + retentionSecs * 1000,
153
+ )
154
+ }
155
+
156
+ /** Release a lease on `key` (upload failed) so the consume can be retried immediately. */
157
+ releaseRedemption(key: string): void {
158
+ this.sql.exec("DELETE FROM redemptions WHERE key = ? AND state = 'leased'", key)
159
+ }
98
160
  }
99
161
 
100
162
  /** {@link NonceBackend} that fans out to per-region {@link NonceRateState} DO shards. */
@@ -136,4 +198,18 @@ export class DurableObjectBackend implements NonceBackend {
136
198
  const shard = this.shardMode === 'global' ? 'g' : region
137
199
  return this.stub(shard).rateCheck(address, maxPerMin)
138
200
  }
201
+
202
+ // Redemption state has no region tag, so all redemption ops route to one fixed shard — keeping
203
+ // every operation on a given `consumeDigest` on the same single-threaded instance (atomic).
204
+ async tryLeaseRedemption(key: string, leaseTtlSecs: number): Promise<LeaseResult> {
205
+ return this.stub(REDEEM_SHARD).tryLeaseRedemption(key, leaseTtlSecs, this.maxEntries)
206
+ }
207
+
208
+ async commitRedemption(key: string, retentionSecs: number): Promise<void> {
209
+ await this.stub(REDEEM_SHARD).commitRedemption(key, retentionSecs)
210
+ }
211
+
212
+ async releaseRedemption(key: string): Promise<void> {
213
+ await this.stub(REDEEM_SHARD).releaseRedemption(key)
214
+ }
139
215
  }
package/src/state/kv.ts CHANGED
@@ -7,13 +7,15 @@
7
7
  * use bind, so that mode is unaffected.) Use the Durable Object backend when this matters.
8
8
  */
9
9
 
10
- import type { NonceBackend } from './types.js'
10
+ import type { LeaseResult, NonceBackend } from './types.js'
11
11
  import { randomHex24 } from './types.js'
12
12
 
13
13
  /** KV key prefix for nonce entries. */
14
14
  const NONCE_PREFIX = 'nonce:'
15
15
  /** KV key prefix for per-address rate-limit windows. */
16
16
  const RATE_PREFIX = 'rate:'
17
+ /** KV key prefix for redemption entries. */
18
+ const REDEEM_PREFIX = 'redeem:'
17
19
  /** Workers KV minimum `expirationTtl` (seconds). Sub-60s logical TTLs are enforced in-value. */
18
20
  const KV_MIN_TTL_SECS = 60
19
21
 
@@ -78,4 +80,45 @@ export class KvBackend implements NonceBackend {
78
80
  })
79
81
  return true
80
82
  }
83
+
84
+ // Best-effort redemption on KV (eventually consistent — the get→put lease is not atomic, so a
85
+ // narrow concurrent-duplicate window exists; the Durable Object backend is strongly consistent
86
+ // and preferred when this matters). Committed always wins over a lease on read.
87
+ async tryLeaseRedemption(key: string, leaseTtlSecs: number): Promise<LeaseResult> {
88
+ const k = REDEEM_PREFIX + key
89
+ const raw = await this.kv.get(k)
90
+ if (raw) {
91
+ try {
92
+ const r = JSON.parse(raw) as { s: string; e: number }
93
+ if (r.s === 'committed') return 'redeemed'
94
+ if (r.s === 'leased' && r.e > Date.now()) return 'leased'
95
+ } catch {
96
+ /* fall through and re-lease */
97
+ }
98
+ }
99
+ await this.kv.put(k, JSON.stringify({ s: 'leased', e: Date.now() + leaseTtlSecs * 1000 }), {
100
+ expirationTtl: Math.max(KV_MIN_TTL_SECS, leaseTtlSecs),
101
+ })
102
+ return 'ok'
103
+ }
104
+
105
+ async commitRedemption(key: string, retentionSecs: number): Promise<void> {
106
+ await this.kv.put(
107
+ REDEEM_PREFIX + key,
108
+ JSON.stringify({ s: 'committed', e: Date.now() + retentionSecs * 1000 }),
109
+ { expirationTtl: Math.max(KV_MIN_TTL_SECS, retentionSecs) },
110
+ )
111
+ }
112
+
113
+ async releaseRedemption(key: string): Promise<void> {
114
+ // Only clear a lease — never a committed marker (that would allow the use to be re-redeemed).
115
+ const raw = await this.kv.get(REDEEM_PREFIX + key)
116
+ if (!raw) return
117
+ try {
118
+ if ((JSON.parse(raw) as { s: string }).s === 'committed') return
119
+ } catch {
120
+ /* malformed — safe to delete */
121
+ }
122
+ await this.kv.delete(REDEEM_PREFIX + key)
123
+ }
81
124
  }
@@ -10,6 +10,13 @@
10
10
  * cross-region replay window in the `SINGLE_USE=false` ownership mode).
11
11
  */
12
12
 
13
+ /**
14
+ * Outcome of a redemption-lease attempt (single-use mode). `ok` — the caller now holds the lease
15
+ * and must `commit`/`release` it. `leased` — another in-flight request holds it (concurrent
16
+ * duplicate). `redeemed` — it was already committed (the use is spent).
17
+ */
18
+ export type LeaseResult = 'ok' | 'leased' | 'redeemed'
19
+
13
20
  export interface NonceBackend {
14
21
  /**
15
22
  * Issue a fresh, time-bound nonce. `region` selects the DO shard (ignored by KV, which is
@@ -20,6 +27,22 @@ export interface NonceBackend {
20
27
  takeIfValid(nonce: string): Promise<boolean>
21
28
  /** Fixed 60s window per verified address. `maxPerMin === 0` disables limiting. */
22
29
  rateCheck(address: string, maxPerMin: number, region: string): Promise<boolean>
30
+
31
+ // ── Single-use redemption (the permanent on-chain `consumeDigest` is the one-time token) ──
32
+ // A use is only spent when an upload actually succeeds: lease the digest, proxy, then commit on
33
+ // success or release on failure. An interrupted attempt leaves the digest redeemable, so a
34
+ // consumed NFT use is never lost. All three are atomic per key on the Durable Object backend.
35
+
36
+ /**
37
+ * Atomically claim `key` for an in-flight upload. `ok` on a fresh/expired-lease/released key,
38
+ * `leased` if another request holds an unexpired lease, `redeemed` if already committed. The
39
+ * lease self-expires after `leaseTtlSecs` so a crashed request cannot strand the key.
40
+ */
41
+ tryLeaseRedemption(key: string, leaseTtlSecs: number): Promise<LeaseResult>
42
+ /** Permanently mark `key` redeemed (retained `retentionSecs`), after a successful upload. */
43
+ commitRedemption(key: string, retentionSecs: number): Promise<void>
44
+ /** Release a lease on `key` (upload failed) so the same consume can be retried immediately. */
45
+ releaseRedemption(key: string): Promise<void>
23
46
  }
24
47
 
25
48
  /**
package/src/verify.ts CHANGED
@@ -117,16 +117,15 @@ function verifyEcdsa(
117
117
  return normalizeAddress(address) === deriveAddress(flag, pk)
118
118
  }
119
119
 
120
- /** On-chain lookups needed to authorise a request. Implemented by {@link ../chain.SuiRpc}. */
120
+ /** On-chain lookups needed to authorise a request. Implemented by {@link ../chain.SuiGrpc}. */
121
121
  export interface ChainQuery {
122
122
  ownsNft(address: string, nftType: string, gateId?: string): Promise<boolean>
123
- consumeEventMatches(
124
- nonce: string,
125
- address: string,
126
- nftType: string,
127
- gateId?: string,
128
- consumeDigest?: string,
129
- ): Promise<boolean>
123
+ /**
124
+ * True if `consumeDigest` names a successful `access_gate::consume` transaction that emitted an
125
+ * `AccessConsumedEvent` for `address` (the sender) on `gateId`. Not bound to the challenge nonce
126
+ * — single-use is enforced by the redemption store keying on the digest.
127
+ */
128
+ consumeTxValid(consumeDigest: string, address: string, gateId?: string): Promise<boolean>
130
129
  }
131
130
 
132
131
  /** The reason a request was rejected by {@link verifyAccessRequest}. Mirror of Rust `Denied`. */
@@ -136,6 +135,7 @@ export type Denied =
136
135
  | 'NonceInvalid'
137
136
  | 'NotOwner'
138
137
  | 'ConsumeMissing'
138
+ | 'RedeemConflict'
139
139
  | 'ChainError'
140
140
 
141
141
  /** A short, client-visible description of why access was denied. */
@@ -150,14 +150,22 @@ export function deniedReason(d: Denied): string {
150
150
  case 'NotOwner':
151
151
  return 'address does not hold the required access NFT'
152
152
  case 'ConsumeMissing':
153
- return 'no matching single-use consume for this challenge'
153
+ return 'no matching single-use consume for this address'
154
+ case 'RedeemConflict':
155
+ return 'this consume is already redeemed or an upload for it is in progress'
154
156
  case 'ChainError':
155
157
  return 'on-chain verification failed'
156
158
  }
157
159
  }
158
160
 
159
- /** Result of {@link verifyAccessRequest}: the verified address on success, or a {@link Denied} reason. */
160
- export type VerifyResult = { ok: true; address: string } | { ok: false; denied: Denied }
161
+ /**
162
+ * Result of {@link verifyAccessRequest}: the verified address on success, or a {@link Denied}
163
+ * reason. In single-use mode a successful result also carries `redemptionKey` (the `consumeDigest`)
164
+ * that the dispatcher leases/commits so the use is only spent on a successful upload.
165
+ */
166
+ export type VerifyResult =
167
+ | { ok: true; address: string; redemptionKey?: string }
168
+ | { ok: false; denied: Denied }
161
169
 
162
170
  /**
163
171
  * Authorise a request from its base64 proof token. The nonce is consumed (single-use at the
@@ -193,26 +201,23 @@ export async function verifyAccessRequest(
193
201
  }
194
202
  let ok: boolean
195
203
  try {
196
- ok = await chain.consumeEventMatches(
197
- proof.nonce,
198
- proof.address,
199
- cfg.nftType,
200
- cfg.gateId,
201
- proof.consumeDigest,
202
- )
204
+ ok = await chain.consumeTxValid(proof.consumeDigest, proof.address, cfg.gateId)
203
205
  } catch {
204
206
  return { ok: false, denied: 'ChainError' }
205
207
  }
206
208
  if (!ok) return { ok: false, denied: 'ConsumeMissing' }
207
- } else {
208
- let ok: boolean
209
- try {
210
- ok = await chain.ownsNft(proof.address, cfg.nftType, cfg.gateId)
211
- } catch {
212
- return { ok: false, denied: 'ChainError' }
213
- }
214
- if (!ok) return { ok: false, denied: 'NotOwner' }
209
+ // The consume is valid on-chain; the dispatcher leases/commits this digest so the use is
210
+ // spent only on a successful upload (and a duplicate can't double-spend it).
211
+ return { ok: true, address: proof.address, redemptionKey: proof.consumeDigest }
212
+ }
213
+
214
+ let ok: boolean
215
+ try {
216
+ ok = await chain.ownsNft(proof.address, cfg.nftType, cfg.gateId)
217
+ } catch {
218
+ return { ok: false, denied: 'ChainError' }
215
219
  }
220
+ if (!ok) return { ok: false, denied: 'NotOwner' }
216
221
 
217
222
  return { ok: true, address: proof.address }
218
223
  }
package/wrangler.toml CHANGED
@@ -52,6 +52,13 @@ RATE_LIMIT_PER_MIN = "30"
52
52
  MAX_BODY_BYTES = "104857600"
53
53
  CHALLENGE_TTL_SECS = "300"
54
54
  OWNERSHIP_CACHE_TTL_MS = "0"
55
+ # Single-use redemption: the permanent on-chain consumeDigest is the one-time token. A use is only
56
+ # spent when an upload actually succeeds (commit); an interrupted upload releases the lease so the
57
+ # consume stays redeemable (the use is never lost). LEASE_TTL bounds an in-flight upload; RETENTION
58
+ # is how long a spent consume is remembered to block re-redemption (30d; a consume older than this
59
+ # could be re-redeemed — an obscure, low-value edge).
60
+ REDEMPTION_LEASE_TTL_SECS = "120"
61
+ REDEMPTION_RETENTION_SECS = "2592000"
55
62
  # Workers-specific:
56
63
  NONCE_BACKEND = "durable-object" # "durable-object" (default) | "kv"
57
64
  NONCE_SHARD = "region" # "region" (default) | "global"