@meddleware/nft-gate-client 0.0.1
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/CHANGELOG.md +17 -0
- package/LICENSE +14 -0
- package/README.md +140 -0
- package/package.json +36 -0
- package/src/challenge.ts +19 -0
- package/src/index.ts +26 -0
- package/src/ownership.ts +99 -0
- package/src/proof.ts +57 -0
- package/src/ptb.ts +65 -0
- package/src/types.ts +72 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.0.1] - 2026-08-27
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial release as `@meddleware/nft-gate-client`
|
|
13
|
+
- `ownsAccessNft`, `fetchAccessNfts`, `fetchAccessNftById`, `parseOwnedAccessNft` — on-chain ownership queries via `@mysten/sui`
|
|
14
|
+
- `buildPurchaseTx`, `buildConsumeTx`, `buildCreateGateTx` — PTB builders for purchase, single-use consume, and gate creation
|
|
15
|
+
- `fetchChallenge` — `GET /v1/challenge` client for the nft-gate gateway wire protocol
|
|
16
|
+
- `personalMessageForNonce`, `buildAccessProof`, `encodeAccessProof`, `decodeAccessProof` — challenge signing and proof token construction
|
|
17
|
+
- Wire protocol compatible with both the Rust/Axum gateway and the Cloudflare Workers gateway in `meddleware-org/nft-gate`
|
package/LICENSE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
BSD Zero Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MeddleWare
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
9
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
10
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
11
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
12
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
13
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
14
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# @meddleware/nft-gate-client
|
|
2
|
+
|
|
3
|
+
[](LICENSE)
|
|
4
|
+
|
|
5
|
+
Client-side TypeScript helpers for the `access_gate` NFT access primitive on Sui. Browser-safe (runs in a wallet app) and Node-friendly. **Client-side only** — server-side verification is provided by the [nft-gate](https://github.com/meddleware-org/nft-gate) service (Rust/Axum gateway or Cloudflare Workers equivalent).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @meddleware/nft-gate-client
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## API
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import {
|
|
17
|
+
ownsAccessNft, fetchAccessNfts, // ownership queries
|
|
18
|
+
buildPurchaseTx, buildConsumeTx, // PTB builders (wallet signs + executes)
|
|
19
|
+
fetchChallenge, // GET /v1/challenge
|
|
20
|
+
buildAccessProof, personalMessageForNonce, // challenge signing + proof token
|
|
21
|
+
} from '@meddleware/nft-gate-client'
|
|
22
|
+
|
|
23
|
+
const cfg = { packageId, gateId, nftType, soulbound: true }
|
|
24
|
+
|
|
25
|
+
// 1. Check whether the connected wallet has access:
|
|
26
|
+
const hasAccess = await ownsAccessNft(suiClient, address, cfg.nftType, cfg.gateId)
|
|
27
|
+
|
|
28
|
+
// 2. Purchase access if not:
|
|
29
|
+
const tx = buildPurchaseTx(cfg, priceMist) // wallet signs & executes
|
|
30
|
+
|
|
31
|
+
// 3. Prove access to a gateway (single-use: submit buildConsumeTx first):
|
|
32
|
+
const challenge = await fetchChallenge(gatewayHost)
|
|
33
|
+
const token = await buildAccessProof({ address, challenge, sign, consumeDigest })
|
|
34
|
+
// pass `token` as the relay/gateway Authorization: Bearer header
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## API Reference
|
|
38
|
+
|
|
39
|
+
### Ownership
|
|
40
|
+
|
|
41
|
+
**`ownsAccessNft(client, address, nftType, gateId): Promise<boolean>`**
|
|
42
|
+
|
|
43
|
+
Returns `true` if the address holds at least one unexpired access NFT matching the gate.
|
|
44
|
+
|
|
45
|
+
**`fetchAccessNfts(client, address, nftType): Promise<OwnedAccessNft[]>`**
|
|
46
|
+
|
|
47
|
+
Returns all access NFTs owned by an address for a given struct type.
|
|
48
|
+
|
|
49
|
+
**`fetchAccessNftById(client, objectId): Promise<OwnedAccessNft | null>`**
|
|
50
|
+
|
|
51
|
+
Fetch a single access NFT by object ID.
|
|
52
|
+
|
|
53
|
+
**`parseOwnedAccessNft(object): OwnedAccessNft | null`**
|
|
54
|
+
|
|
55
|
+
Parse a raw `SuiObjectResponse` into an `OwnedAccessNft`. Returns `null` if the object is not a valid access NFT.
|
|
56
|
+
|
|
57
|
+
### PTB Builders
|
|
58
|
+
|
|
59
|
+
**`buildPurchaseTx(config, priceMist): Transaction`**
|
|
60
|
+
|
|
61
|
+
Build a transaction to purchase an access NFT. Caller signs and executes with their wallet.
|
|
62
|
+
|
|
63
|
+
**`buildConsumeTx(config, nftObjectId): Transaction`**
|
|
64
|
+
|
|
65
|
+
Build a transaction to consume (burn) a single-use access NFT, proving use. Submit before calling `buildAccessProof` with the resulting `consumeDigest`.
|
|
66
|
+
|
|
67
|
+
**`buildCreateGateTx(config): Transaction`**
|
|
68
|
+
|
|
69
|
+
Build a transaction to create a new access gate on-chain (admin operation).
|
|
70
|
+
|
|
71
|
+
### Challenge & Proof
|
|
72
|
+
|
|
73
|
+
**`fetchChallenge(gatewayHost, opts?): Promise<Challenge>`**
|
|
74
|
+
|
|
75
|
+
Fetch a time-bound nonce from the gateway's `GET /v1/challenge` endpoint.
|
|
76
|
+
|
|
77
|
+
**`personalMessageForNonce(nonce): Uint8Array`**
|
|
78
|
+
|
|
79
|
+
Returns the exact bytes the wallet must sign for a nonce. Matches the gateway's derivation: `nft-gate:access:<nonce>`.
|
|
80
|
+
|
|
81
|
+
**`buildAccessProof(opts): Promise<string>`**
|
|
82
|
+
|
|
83
|
+
One-shot helper: sign the challenge with the wallet and return the base64(JSON) Bearer token to pass to the gateway or relay.
|
|
84
|
+
|
|
85
|
+
**`encodeAccessProof(proof: AccessProof): string`**
|
|
86
|
+
|
|
87
|
+
Encode a proof struct directly to a base64(JSON) token (lower-level, sync).
|
|
88
|
+
|
|
89
|
+
**`decodeAccessProof(token: string): AccessProof`**
|
|
90
|
+
|
|
91
|
+
Decode a base64(JSON) token back to a proof struct.
|
|
92
|
+
|
|
93
|
+
## Wire protocol
|
|
94
|
+
|
|
95
|
+
- **Challenge**: `{ nonce: string, expiresAt: number }`
|
|
96
|
+
- **Signed message**: `nft-gate:access:<nonce>` (UTF-8 bytes)
|
|
97
|
+
- **Proof token**: `base64(JSON { address, nonce, signature, consumeDigest? })`
|
|
98
|
+
|
|
99
|
+
This format is verified by both the Rust gateway and the Cloudflare Workers gateway in the [nft-gate](https://github.com/meddleware-org/nft-gate) repo.
|
|
100
|
+
|
|
101
|
+
## Types
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
interface AccessGateConfig {
|
|
105
|
+
packageId: string
|
|
106
|
+
gateId: string
|
|
107
|
+
nftType: string
|
|
108
|
+
soulbound: boolean
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface Challenge {
|
|
112
|
+
nonce: string
|
|
113
|
+
expiresAt: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface AccessProof {
|
|
117
|
+
address: string
|
|
118
|
+
nonce: string
|
|
119
|
+
signature: string
|
|
120
|
+
consumeDigest?: string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface OwnedAccessNft {
|
|
124
|
+
objectId: string
|
|
125
|
+
gateId: string
|
|
126
|
+
expiresAt?: number
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
npm run type-check # tsc --noEmit
|
|
134
|
+
npm test # vitest run (19 unit tests)
|
|
135
|
+
npm run test:watch # vitest interactive
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
[0BSD](LICENSE)
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@meddleware/nft-gate-client",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Client-side helpers for the access_gate NFT access primitive: ownership queries, purchase/consume PTB builders, challenge signing, and access-proof assembly.",
|
|
6
|
+
"author": "MeddleWare <meddleware@proton.me>",
|
|
7
|
+
"license": "0BSD",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/meddleware-org/nft-gate-client.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["sui", "nft", "access-control", "web3", "move"],
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"CHANGELOG.md"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"type-check": "tsc --noEmit",
|
|
22
|
+
"test": "vitest run",
|
|
23
|
+
"test:watch": "vitest"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@mysten/sui": "~2.17.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "~24.12.2",
|
|
30
|
+
"typescript": "~6.0.3",
|
|
31
|
+
"vitest": "~4.1.10"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/challenge.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Challenge } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fetch a fresh challenge from a gateway's `GET /v1/challenge` endpoint. Tolerates both
|
|
5
|
+
* `expiresAt` (camelCase) and `expires_at` (snake_case) response shapes.
|
|
6
|
+
*/
|
|
7
|
+
export async function fetchChallenge(
|
|
8
|
+
gatewayHost: string,
|
|
9
|
+
opts: { signal?: AbortSignal } = {},
|
|
10
|
+
): Promise<Challenge> {
|
|
11
|
+
const res = await fetch(`${gatewayHost.replace(/\/$/, '')}/v1/challenge`, {
|
|
12
|
+
signal: opts.signal,
|
|
13
|
+
})
|
|
14
|
+
if (!res.ok) throw new Error(`challenge request failed: ${res.status}`)
|
|
15
|
+
const data = (await res.json()) as { nonce?: string; expiresAt?: number; expires_at?: number }
|
|
16
|
+
if (!data || typeof data.nonce !== 'string') throw new Error('challenge response missing nonce')
|
|
17
|
+
const expiresAt = data.expiresAt ?? data.expires_at ?? 0
|
|
18
|
+
return { nonce: data.nonce, expiresAt }
|
|
19
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@meddleware/nft-gate-client` — client-side helpers for the `access_gate` primitive.
|
|
3
|
+
*
|
|
4
|
+
* Client-side only: ownership queries, purchase/consume PTB builders, challenge signing,
|
|
5
|
+
* and access-proof assembly. Server-side verification lives in the Rust gateway.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type {
|
|
9
|
+
AccessGateConfig,
|
|
10
|
+
Challenge,
|
|
11
|
+
AccessProof,
|
|
12
|
+
OwnedAccessNft,
|
|
13
|
+
OwnedObjectsClient,
|
|
14
|
+
SuiObjectClient,
|
|
15
|
+
} from './types.js'
|
|
16
|
+
|
|
17
|
+
export { fetchAccessNfts, ownsAccessNft, parseOwnedAccessNft, fetchAccessNftById } from './ownership.js'
|
|
18
|
+
export { buildPurchaseTx, buildConsumeTx, buildCreateGateTx } from './ptb.js'
|
|
19
|
+
export { fetchChallenge } from './challenge.js'
|
|
20
|
+
export {
|
|
21
|
+
personalMessageForNonce,
|
|
22
|
+
encodeAccessProof,
|
|
23
|
+
decodeAccessProof,
|
|
24
|
+
buildAccessProof,
|
|
25
|
+
} from './proof.js'
|
|
26
|
+
export type { PersonalMessageSigner } from './proof.js'
|
package/src/ownership.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { OwnedAccessNft, OwnedObjectsClient, SuiObjectClient } from './types.js'
|
|
2
|
+
|
|
3
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extract `uses_remaining` from a Move enum `AccessVariant` as rendered by RPC, driven by the
|
|
7
|
+
* enum **variant tag** (typed) rather than guessing from field presence. Sui renders a Move
|
|
8
|
+
* enum as `{ variant: 'SingleUse' | 'UnlimitedPass', fields: {...} }`. Returns `null` for an
|
|
9
|
+
* unlimited pass; the remaining count for a single-use.
|
|
10
|
+
*/
|
|
11
|
+
function parseUsesRemaining(variant: any): number | null {
|
|
12
|
+
if (variant == null) return null
|
|
13
|
+
const tag: string | undefined = variant.variant ?? variant.type ?? variant.$kind
|
|
14
|
+
if (tag === 'UnlimitedPass') return null
|
|
15
|
+
const fields = variant.fields ?? variant
|
|
16
|
+
const ur = fields?.uses_remaining ?? fields?.SingleUse?.uses_remaining
|
|
17
|
+
if (ur != null) return Number(ur)
|
|
18
|
+
// Tagged SingleUse but the count is missing from this node's rendering — unknown, not a pass.
|
|
19
|
+
return tag === 'SingleUse' ? null : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** True if `type` names an `access_gate` NFT struct (transferable or soulbound). */
|
|
23
|
+
function isAccessNftType(type: unknown): boolean {
|
|
24
|
+
return typeof type === 'string' && /::access_gate::(Soulbound)?AccessNFT\b/.test(type)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse a single `getOwnedObjects`/`getObject` entry into an {@link OwnedAccessNft}, or `null`
|
|
29
|
+
* if it is not an access NFT. Validates the object **type** when present (typed), and reads the
|
|
30
|
+
* nested `data.fields` deterministically.
|
|
31
|
+
*/
|
|
32
|
+
export function parseOwnedAccessNft(entry: any): OwnedAccessNft | null {
|
|
33
|
+
const obj = entry?.data ?? entry
|
|
34
|
+
const objectId: string | undefined = obj?.objectId ?? obj?.content?.fields?.id?.id
|
|
35
|
+
const type: unknown = obj?.type ?? obj?.content?.type
|
|
36
|
+
// When the type is present it MUST be an access NFT; when absent (some node shapes) fall back
|
|
37
|
+
// to structural checks below.
|
|
38
|
+
if (type !== undefined && !isAccessNftType(type)) return null
|
|
39
|
+
const inner = obj?.content?.fields?.data?.fields
|
|
40
|
+
const gateId: string | undefined = inner?.gate_id ?? inner?.gateId
|
|
41
|
+
if (!objectId || !gateId) return null
|
|
42
|
+
return {
|
|
43
|
+
objectId,
|
|
44
|
+
gateId,
|
|
45
|
+
usesRemaining: parseUsesRemaining(inner?.variant),
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Typed single-object read of one access NFT by id (`getObject` with `showType`+`showContent`),
|
|
51
|
+
* used when a UI needs the **exact** `usesRemaining` reliably rather than the best-effort parse
|
|
52
|
+
* of an owned-objects page. Returns `null` if the object is missing or not an access NFT.
|
|
53
|
+
*/
|
|
54
|
+
export async function fetchAccessNftById(
|
|
55
|
+
client: SuiObjectClient,
|
|
56
|
+
objectId: string,
|
|
57
|
+
): Promise<OwnedAccessNft | null> {
|
|
58
|
+
const res = await client.getObject({
|
|
59
|
+
id: objectId,
|
|
60
|
+
options: { showType: true, showContent: true },
|
|
61
|
+
})
|
|
62
|
+
return parseOwnedAccessNft(res)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Fetch all access NFTs of `nftType` owned by `owner`, optionally restricted to a specific
|
|
67
|
+
* `gateId`. Uses `getOwnedObjects` filtered by `StructType` (the standard owned-objects query).
|
|
68
|
+
*/
|
|
69
|
+
export async function fetchAccessNfts(
|
|
70
|
+
client: OwnedObjectsClient,
|
|
71
|
+
owner: string,
|
|
72
|
+
nftType: string,
|
|
73
|
+
gateId?: string,
|
|
74
|
+
): Promise<OwnedAccessNft[]> {
|
|
75
|
+
const { data } = await client.getOwnedObjects({
|
|
76
|
+
owner,
|
|
77
|
+
filter: { StructType: nftType },
|
|
78
|
+
options: { showContent: true, showType: true },
|
|
79
|
+
})
|
|
80
|
+
const parsed = (data ?? [])
|
|
81
|
+
.map(parseOwnedAccessNft)
|
|
82
|
+
.filter((n): n is OwnedAccessNft => n !== null)
|
|
83
|
+
return gateId ? parsed.filter((n) => n.gateId === gateId) : parsed
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* True if `owner` holds at least one access NFT of `nftType` (optionally for `gateId`).
|
|
88
|
+
* This is the cheap check a frontend runs to decide whether to show a gated option, and a
|
|
89
|
+
* gateway runs (server-side) as part of access verification.
|
|
90
|
+
*/
|
|
91
|
+
export async function ownsAccessNft(
|
|
92
|
+
client: OwnedObjectsClient,
|
|
93
|
+
owner: string,
|
|
94
|
+
nftType: string,
|
|
95
|
+
gateId?: string,
|
|
96
|
+
): Promise<boolean> {
|
|
97
|
+
const nfts = await fetchAccessNfts(client, owner, nftType, gateId)
|
|
98
|
+
return nfts.length > 0
|
|
99
|
+
}
|
package/src/proof.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { AccessProof, Challenge } from './types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The exact bytes a wallet signs (as a personal message) to answer a challenge. Both the
|
|
5
|
+
* client (signing) and the gateway (verifying) MUST derive the message identically.
|
|
6
|
+
*/
|
|
7
|
+
export function personalMessageForNonce(nonce: string): Uint8Array {
|
|
8
|
+
return new TextEncoder().encode(`nft-gate:access:${nonce}`)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function toBase64(s: string): string {
|
|
12
|
+
// Works in browsers and modern Node (globalThis.btoa is available on Node >= 16).
|
|
13
|
+
if (typeof btoa === 'function') return btoa(s)
|
|
14
|
+
return Buffer.from(s, 'utf-8').toString('base64')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function fromBase64(s: string): string {
|
|
18
|
+
if (typeof atob === 'function') return atob(s)
|
|
19
|
+
return Buffer.from(s, 'base64').toString('utf-8')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Encode a proof as the compact Bearer token carried in the relay auth header. */
|
|
23
|
+
export function encodeAccessProof(proof: AccessProof): string {
|
|
24
|
+
return toBase64(JSON.stringify(proof))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Decode a proof token produced by {@link encodeAccessProof}. Throws on malformed input. */
|
|
28
|
+
export function decodeAccessProof(token: string): AccessProof {
|
|
29
|
+
const raw = JSON.parse(fromBase64(token)) as Partial<AccessProof>
|
|
30
|
+
if (!raw || typeof raw.address !== 'string' || typeof raw.nonce !== 'string' || typeof raw.signature !== 'string') {
|
|
31
|
+
throw new Error('malformed access proof')
|
|
32
|
+
}
|
|
33
|
+
const proof: AccessProof = { address: raw.address, nonce: raw.nonce, signature: raw.signature }
|
|
34
|
+
if (typeof raw.consumeDigest === 'string') proof.consumeDigest = raw.consumeDigest
|
|
35
|
+
return proof
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A wallet-provided personal-message signer (e.g. wallet-standard `sui:signPersonalMessage`). */
|
|
39
|
+
export type PersonalMessageSigner = (message: Uint8Array) => Promise<{ signature: string }>
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Sign a challenge and assemble the encoded access-proof token to hand to any gateway as its
|
|
43
|
+
* auth bearer (e.g. an upload-relay client's auth-token option, an `Authorization` header).
|
|
44
|
+
*/
|
|
45
|
+
export async function buildAccessProof(opts: {
|
|
46
|
+
address: string
|
|
47
|
+
challenge: Challenge
|
|
48
|
+
sign: PersonalMessageSigner
|
|
49
|
+
/** Present for single-use gates: the `consume` tx digest. */
|
|
50
|
+
consumeDigest?: string
|
|
51
|
+
}): Promise<string> {
|
|
52
|
+
const message = personalMessageForNonce(opts.challenge.nonce)
|
|
53
|
+
const { signature } = await opts.sign(message)
|
|
54
|
+
const proof: AccessProof = { address: opts.address, nonce: opts.challenge.nonce, signature }
|
|
55
|
+
if (opts.consumeDigest) proof.consumeDigest = opts.consumeDigest
|
|
56
|
+
return encodeAccessProof(proof)
|
|
57
|
+
}
|
package/src/ptb.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Transaction } from '@mysten/sui/transactions'
|
|
2
|
+
import type { AccessGateConfig } from './types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build a PTB that purchases access: split `priceMist` from the gas coin and call
|
|
6
|
+
* `access_gate::purchase(gate, payment)`. Overpayment is refunded on-chain, so the split
|
|
7
|
+
* must be exactly the price. The caller signs + executes with their wallet.
|
|
8
|
+
*/
|
|
9
|
+
export function buildPurchaseTx(cfg: AccessGateConfig, priceMist: bigint | number): Transaction {
|
|
10
|
+
const tx = new Transaction()
|
|
11
|
+
const [payment] = tx.splitCoins(tx.gas, [tx.pure.u64(priceMist)])
|
|
12
|
+
tx.moveCall({
|
|
13
|
+
target: `${cfg.packageId}::access_gate::purchase`,
|
|
14
|
+
arguments: [tx.object(cfg.gateId), payment],
|
|
15
|
+
})
|
|
16
|
+
return tx
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build a PTB that consumes one use of a single-use NFT, binding it to `nonce`. Selects
|
|
21
|
+
* `consume` or `consume_soulbound` from `cfg.soulbound`. For unlimited passes there is
|
|
22
|
+
* nothing to consume — do not call this.
|
|
23
|
+
*/
|
|
24
|
+
export function buildConsumeTx(
|
|
25
|
+
cfg: AccessGateConfig,
|
|
26
|
+
nftId: string,
|
|
27
|
+
nonce: string,
|
|
28
|
+
): Transaction {
|
|
29
|
+
const tx = new Transaction()
|
|
30
|
+
const fn = cfg.soulbound ? 'consume_soulbound' : 'consume'
|
|
31
|
+
const nonceBytes = Array.from(new TextEncoder().encode(nonce))
|
|
32
|
+
tx.moveCall({
|
|
33
|
+
target: `${cfg.packageId}::access_gate::${fn}`,
|
|
34
|
+
arguments: [tx.object(nftId), tx.object(cfg.gateId), tx.pure.vector('u8', nonceBytes)],
|
|
35
|
+
})
|
|
36
|
+
return tx
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build a PTB that creates a new gate. Mostly for tooling/operators; the frontend usually
|
|
41
|
+
* only purchases/consumes an existing gate.
|
|
42
|
+
*/
|
|
43
|
+
export function buildCreateGateTx(
|
|
44
|
+
packageId: string,
|
|
45
|
+
opts: {
|
|
46
|
+
priceMist: bigint | number
|
|
47
|
+
paymentRecipient: string
|
|
48
|
+
defaultUses: bigint | number
|
|
49
|
+
soulbound: boolean
|
|
50
|
+
autoBurnAtZero: boolean
|
|
51
|
+
},
|
|
52
|
+
): Transaction {
|
|
53
|
+
const tx = new Transaction()
|
|
54
|
+
tx.moveCall({
|
|
55
|
+
target: `${packageId}::access_gate::create_gate`,
|
|
56
|
+
arguments: [
|
|
57
|
+
tx.pure.u64(opts.priceMist),
|
|
58
|
+
tx.pure.address(opts.paymentRecipient),
|
|
59
|
+
tx.pure.u64(opts.defaultUses),
|
|
60
|
+
tx.pure.bool(opts.soulbound),
|
|
61
|
+
tx.pure.bool(opts.autoBurnAtZero),
|
|
62
|
+
],
|
|
63
|
+
})
|
|
64
|
+
return tx
|
|
65
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared wire + config types for the access-gate client.
|
|
3
|
+
*
|
|
4
|
+
* These mirror the on-chain `access_gate` Move package and the challenge/proof wire
|
|
5
|
+
* protocol implemented by the generic Rust gateway. Keep the three in sync.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Identifies a deployed gate and the NFT type that satisfies it. */
|
|
9
|
+
export interface AccessGateConfig {
|
|
10
|
+
/** Published `access_gate` package ID. */
|
|
11
|
+
packageId: string
|
|
12
|
+
/** The shared `Gate` object ID. */
|
|
13
|
+
gateId: string
|
|
14
|
+
/**
|
|
15
|
+
* Fully-qualified NFT type string to filter ownership by, e.g.
|
|
16
|
+
* `<pkg>::access_gate::AccessNFT` or `<pkg>::access_gate::SoulboundAccessNFT`.
|
|
17
|
+
* Choose the variant matching the gate's `soulbound` flag.
|
|
18
|
+
*/
|
|
19
|
+
nftType: string
|
|
20
|
+
/** Whether this gate mints soulbound NFTs (selects `consume` vs `consume_soulbound`). */
|
|
21
|
+
soulbound?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A server-issued, time-bound challenge the wallet signs to prove control of an address. */
|
|
25
|
+
export interface Challenge {
|
|
26
|
+
/** Opaque nonce (as issued by the gateway; treated as a UTF-8 string end-to-end). */
|
|
27
|
+
nonce: string
|
|
28
|
+
/** Unix epoch milliseconds after which the challenge is rejected. */
|
|
29
|
+
expiresAt: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The proof a client presents to a gateway to demonstrate gated access. */
|
|
33
|
+
export interface AccessProof {
|
|
34
|
+
/** The Sui address claimed by the caller. */
|
|
35
|
+
address: string
|
|
36
|
+
/** The challenge nonce that was signed. */
|
|
37
|
+
nonce: string
|
|
38
|
+
/** Base64 personal-message signature over {@link personalMessageForNonce}. */
|
|
39
|
+
signature: string
|
|
40
|
+
/**
|
|
41
|
+
* For single-use gates: the digest of the on-chain `consume(nft, nonce)` transaction, so
|
|
42
|
+
* the gateway can confirm the matching `AccessConsumedEvent` before allowing the request.
|
|
43
|
+
*/
|
|
44
|
+
consumeDigest?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A parsed owned access NFT. */
|
|
48
|
+
export interface OwnedAccessNft {
|
|
49
|
+
objectId: string
|
|
50
|
+
gateId: string
|
|
51
|
+
/** `null` for an unlimited pass; otherwise remaining single-use count. */
|
|
52
|
+
usesRemaining: number | null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Minimal structural subset of a Sui client used for ownership queries (grpc or json-rpc). */
|
|
56
|
+
export interface OwnedObjectsClient {
|
|
57
|
+
getOwnedObjects(params: {
|
|
58
|
+
owner: string
|
|
59
|
+
filter?: { StructType: string }
|
|
60
|
+
options?: { showContent?: boolean; showType?: boolean }
|
|
61
|
+
cursor?: string | null
|
|
62
|
+
limit?: number | null
|
|
63
|
+
}): Promise<{ data: unknown[]; hasNextPage?: boolean; nextCursor?: string | null }>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Minimal structural subset of a Sui client used for a typed single-object read. */
|
|
67
|
+
export interface SuiObjectClient {
|
|
68
|
+
getObject(params: {
|
|
69
|
+
id: string
|
|
70
|
+
options?: { showContent?: boolean; showType?: boolean }
|
|
71
|
+
}): Promise<unknown>
|
|
72
|
+
}
|