@insumermodel/mppx-token-gate 1.0.0

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/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # @insumermodel/mppx-token-gate
2
+
3
+ Token-gate [mppx](https://github.com/wevm/mppx) routes using signed wallet attestations. One API call checks token/NFT ownership across 30 EVM chains + Solana + XRPL — no RPC management, no viem transports, no in-house balance checks.
4
+
5
+ ## How it works
6
+
7
+ mppx embeds the payer's identity in every payment credential as a DID string (`credential.source: "did:pkh:eip155:8453:0xABC..."`). `tokenGate` reads that address, calls [InsumerAPI](https://insumermodel.com) to check on-chain ownership, and short-circuits the payment flow for holders.
8
+
9
+ 1. Request arrives with a payment credential
10
+ 2. `tokenGate` extracts the payer address from `credential.source`
11
+ 3. [InsumerAPI](https://insumermodel.com/developers/api-reference/) checks ownership and returns an ECDSA P-256 signed result
12
+ 4. **Token holder** → free receipt returned (`reference: "token-gate:free:{attestationId}"`)
13
+ 5. **Non-holder** → delegates to the original `verify` (normal payment proceeds)
14
+
15
+ The attestation signature is verifiable offline via [JWKS](https://insumermodel.com/.well-known/jwks.json) — downstream services can independently verify the gate decision.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ npm install @insumermodel/mppx-token-gate
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ ```ts
26
+ import { Mppx, tempo } from 'mppx/server'
27
+ import { tokenGate } from '@insumermodel/mppx-token-gate'
28
+
29
+ const tempoCharge = tempo({
30
+ currency: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
31
+ recipient: '0xYourAddress',
32
+ })
33
+
34
+ const gatedCharge = tokenGate(tempoCharge, {
35
+ apiKey: process.env.INSUMER_API_KEY,
36
+ conditions: [{
37
+ type: 'nft_ownership',
38
+ contractAddress: '0xYourNFT',
39
+ chainId: 8453, // Base
40
+ }],
41
+ })
42
+
43
+ const mppx = Mppx.create({ methods: [gatedCharge] })
44
+ ```
45
+
46
+ Works with any framework (Hono, Express, Elysia, Next.js) and any payment method (tempo, stripe) — it wraps `Method.Server`, so no middleware changes needed.
47
+
48
+ ## API key
49
+
50
+ Get a free key (instant, no signup):
51
+
52
+ ```bash
53
+ curl -X POST https://api.insumermodel.com/v1/keys/create \
54
+ -H "Content-Type: application/json" \
55
+ -d '{"email":"you@example.com","appName":"my-app","tier":"free"}'
56
+ ```
57
+
58
+ Or set `INSUMER_API_KEY` as an environment variable.
59
+
60
+ ## Options
61
+
62
+ | Option | Type | Default | Description |
63
+ |---|---|---|---|
64
+ | `apiKey` | `string` | `env.INSUMER_API_KEY` | InsumerAPI key |
65
+ | `conditions` | `TokenCondition[]` | — | Token/NFT conditions to check |
66
+ | `matchMode` | `'any' \| 'all'` | `'any'` | Whether holder must satisfy any or all conditions |
67
+ | `cacheTtlSeconds` | `number` | `300` | In-memory ownership cache TTL |
68
+ | `jwt` | `boolean` | `false` | Request ES256 JWT alongside raw attestation |
69
+ | `apiBaseUrl` | `string` | `https://api.insumermodel.com` | API base URL override |
70
+
71
+ ### TokenCondition
72
+
73
+ | Field | Type | Description |
74
+ |---|---|---|
75
+ | `contractAddress` | `string` | Token/NFT contract address |
76
+ | `chainId` | `number \| 'solana' \| 'xrpl'` | Chain identifier |
77
+ | `type` | `'token_balance' \| 'nft_ownership'` | Condition type |
78
+ | `threshold` | `number` | Min balance (token_balance only, default 1) |
79
+ | `decimals` | `number` | Token decimals (auto-detected on most EVM chains) |
80
+ | `label` | `string` | Human-readable label |
81
+ | `currency` | `string` | XRPL currency code |
82
+ | `taxon` | `number` | XRPL NFT taxon filter |
83
+
84
+ ## Supported chains
85
+
86
+ 30 EVM chains (Ethereum, Base, Polygon, Arbitrum, Optimism, BNB, Avalanche, and 23 more) + Solana + XRPL.
87
+
88
+ [Full chain list](https://insumermodel.com/developers/api-reference/)
89
+
90
+ ## Distinguishing free vs paid access
91
+
92
+ ```ts
93
+ const receipt = Receipt.fromResponse(response)
94
+ if (receipt.reference.startsWith('token-gate:free:')) {
95
+ // Free access — attestation ID is in the reference
96
+ const attestationId = receipt.reference.replace('token-gate:free:', '')
97
+ } else {
98
+ // Paid access
99
+ }
100
+ ```
101
+
102
+ ## Fail-open behavior
103
+
104
+ If the attestation API is unreachable, the wrapper falls through to the original payment method. Token holders pay normally; non-holders are unaffected.
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,115 @@
1
+ import type { Method } from 'mppx';
2
+ export type TokenCondition = {
3
+ /** Token or NFT contract address. For XRPL native XRP, use "native". */
4
+ contractAddress: string;
5
+ /**
6
+ * Chain identifier.
7
+ * EVM chain ID (number), "solana", or "xrpl".
8
+ */
9
+ chainId: number | 'solana' | 'xrpl';
10
+ /** "token_balance" or "nft_ownership". */
11
+ type: 'token_balance' | 'nft_ownership';
12
+ /** Minimum balance for token_balance conditions. Defaults to 1. */
13
+ threshold?: number;
14
+ /** Token decimals (auto-detected on most EVM chains if omitted). */
15
+ decimals?: number;
16
+ /** Human-readable label (max 100 chars). */
17
+ label?: string;
18
+ /** XRPL currency code (e.g. "USD", "RLUSD"). Only for XRPL trust-line tokens. */
19
+ currency?: string;
20
+ /** XRPL NFT taxon filter. Only for XRPL nft_ownership. */
21
+ taxon?: number;
22
+ };
23
+ export type TokenGateOptions = {
24
+ /** InsumerAPI key. Falls back to INSUMER_API_KEY env var. */
25
+ apiKey?: string;
26
+ /** One or more token/NFT conditions to check. */
27
+ conditions: TokenCondition[];
28
+ /** Whether the payer must satisfy "any" (default) or "all" conditions. */
29
+ matchMode?: 'any' | 'all';
30
+ /** In-memory cache TTL in seconds. Defaults to 300 (5 minutes). */
31
+ cacheTtlSeconds?: number;
32
+ /**
33
+ * InsumerAPI base URL. Defaults to "https://api.insumermodel.com".
34
+ * Override for testing or self-hosted deployments.
35
+ */
36
+ apiBaseUrl?: string;
37
+ /** Request JWT format alongside the raw attestation. Defaults to false. */
38
+ jwt?: boolean;
39
+ };
40
+ export type InsumerAttestation = {
41
+ ok: boolean;
42
+ data: {
43
+ attestation: {
44
+ id: string;
45
+ pass: boolean;
46
+ results: Array<{
47
+ condition: number;
48
+ label?: string;
49
+ type: string;
50
+ chainId: number | string;
51
+ met: boolean;
52
+ evaluatedCondition: Record<string, unknown>;
53
+ conditionHash: string;
54
+ blockNumber?: string;
55
+ blockTimestamp?: string;
56
+ ledgerIndex?: number;
57
+ ledgerHash?: string;
58
+ }>;
59
+ passCount: number;
60
+ failCount: number;
61
+ attestedAt: string;
62
+ expiresAt: string;
63
+ };
64
+ sig: string;
65
+ kid: string;
66
+ jwt?: string;
67
+ };
68
+ meta: {
69
+ version: string;
70
+ timestamp: string;
71
+ creditsRemaining: number;
72
+ creditsCharged: number;
73
+ };
74
+ };
75
+ /** Clear the in-memory ownership cache. Useful in tests. */
76
+ export declare function clearTokenGateCache(): void;
77
+ /**
78
+ * Extracts an EVM address from a `did:pkh:eip155:{chainId}:{address}` string.
79
+ * Returns null for non-EVM or unparseable DIDs.
80
+ */
81
+ export declare function parseDid(source: string): `0x${string}` | null;
82
+ /**
83
+ * Extracts a Solana address from a `did:pkh:solana:{chainId}:{address}` string.
84
+ */
85
+ export declare function parseSolanaDid(source: string): string | null;
86
+ /**
87
+ * Extracts an XRPL address from a `did:pkh:xrpl:{chainId}:{address}` string.
88
+ */
89
+ export declare function parsXrplDid(source: string): string | null;
90
+ /**
91
+ * Wraps an mppx `Method.Server` to grant free access to token holders.
92
+ *
93
+ * Extracts the payer address from `credential.source` (DID), calls InsumerAPI
94
+ * to check token/NFT ownership across 32 chains, and returns a free receipt
95
+ * for holders. Non-holders fall through to the original payment method.
96
+ *
97
+ * The attestation is ECDSA P-256 signed and verifiable offline via JWKS.
98
+ *
99
+ * @example
100
+ * ```ts
101
+ * import { tokenGate } from 'mppx-token-gate'
102
+ *
103
+ * const gatedCharge = tokenGate(tempoCharge, {
104
+ * conditions: [{
105
+ * type: 'nft_ownership',
106
+ * contractAddress: '0xYourNFT',
107
+ * chainId: 8453,
108
+ * }],
109
+ * })
110
+ *
111
+ * const mppx = Mppx.create({ methods: [gatedCharge] })
112
+ * ```
113
+ */
114
+ export declare function tokenGate(server: Method.AnyServer, options: TokenGateOptions): Method.AnyServer;
115
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,217 @@
1
+ const cache = new Map();
2
+ function cacheKey(address, conditions) {
3
+ const sorted = [...conditions].sort((a, b) => {
4
+ const ca = `${a.chainId}:${a.contractAddress}`.toLowerCase();
5
+ const cb = `${b.chainId}:${b.contractAddress}`.toLowerCase();
6
+ return ca < cb ? -1 : ca > cb ? 1 : 0;
7
+ });
8
+ return `${address.toLowerCase()}:${JSON.stringify(sorted)}`;
9
+ }
10
+ /** Clear the in-memory ownership cache. Useful in tests. */
11
+ export function clearTokenGateCache() {
12
+ cache.clear();
13
+ }
14
+ // ---------------------------------------------------------------------------
15
+ // DID parsing
16
+ // ---------------------------------------------------------------------------
17
+ /**
18
+ * Extracts an EVM address from a `did:pkh:eip155:{chainId}:{address}` string.
19
+ * Returns null for non-EVM or unparseable DIDs.
20
+ */
21
+ export function parseDid(source) {
22
+ const parts = source.split(':');
23
+ if (parts.length !== 5)
24
+ return null;
25
+ if (parts[0] !== 'did' || parts[1] !== 'pkh' || parts[2] !== 'eip155')
26
+ return null;
27
+ const address = parts[4];
28
+ if (!address || !address.startsWith('0x'))
29
+ return null;
30
+ return address;
31
+ }
32
+ /**
33
+ * Extracts a Solana address from a `did:pkh:solana:{chainId}:{address}` string.
34
+ */
35
+ export function parseSolanaDid(source) {
36
+ const parts = source.split(':');
37
+ if (parts.length !== 5)
38
+ return null;
39
+ if (parts[0] !== 'did' || parts[1] !== 'pkh' || parts[2] !== 'solana')
40
+ return null;
41
+ return parts[4] || null;
42
+ }
43
+ /**
44
+ * Extracts an XRPL address from a `did:pkh:xrpl:{chainId}:{address}` string.
45
+ */
46
+ export function parsXrplDid(source) {
47
+ const parts = source.split(':');
48
+ if (parts.length !== 5)
49
+ return null;
50
+ if (parts[0] !== 'did' || parts[1] !== 'pkh' || parts[2] !== 'xrpl')
51
+ return null;
52
+ const address = parts[4];
53
+ if (!address || !address.startsWith('r'))
54
+ return null;
55
+ return address;
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // InsumerAPI call
59
+ // ---------------------------------------------------------------------------
60
+ async function callAttest(wallet, walletType, conditions, options) {
61
+ const apiKey = options.apiKey || process.env.INSUMER_API_KEY;
62
+ if (!apiKey) {
63
+ throw new Error('mppx-token-gate: Missing API key. Pass apiKey in options or set INSUMER_API_KEY env var. ' +
64
+ 'Get a free key: POST https://api.insumermodel.com/v1/keys/create');
65
+ }
66
+ const baseUrl = options.apiBaseUrl || 'https://api.insumermodel.com';
67
+ const body = {
68
+ conditions: conditions.map((c) => {
69
+ const cond = {
70
+ type: c.type,
71
+ contractAddress: c.contractAddress,
72
+ chainId: c.chainId,
73
+ };
74
+ if (c.type === 'token_balance') {
75
+ cond.threshold = c.threshold ?? 1;
76
+ }
77
+ if (c.decimals !== undefined)
78
+ cond.decimals = c.decimals;
79
+ if (c.label)
80
+ cond.label = c.label;
81
+ if (c.currency)
82
+ cond.currency = c.currency;
83
+ if (c.taxon !== undefined)
84
+ cond.taxon = c.taxon;
85
+ return cond;
86
+ }),
87
+ };
88
+ if (walletType === 'solana')
89
+ body.solanaWallet = wallet;
90
+ else if (walletType === 'xrpl')
91
+ body.xrplWallet = wallet;
92
+ else
93
+ body.wallet = wallet;
94
+ if (options.jwt)
95
+ body.format = 'jwt';
96
+ const response = await fetch(`${baseUrl}/v1/attest`, {
97
+ method: 'POST',
98
+ headers: {
99
+ 'Content-Type': 'application/json',
100
+ 'x-api-key': apiKey,
101
+ },
102
+ body: JSON.stringify(body),
103
+ });
104
+ const data = await response.json();
105
+ if (!response.ok || !data.ok) {
106
+ const msg = data?.error?.message || `HTTP ${response.status}`;
107
+ throw new Error(`mppx-token-gate: Attestation failed — ${msg}`);
108
+ }
109
+ return data;
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // tokenGate wrapper
113
+ // ---------------------------------------------------------------------------
114
+ /**
115
+ * Wraps an mppx `Method.Server` to grant free access to token holders.
116
+ *
117
+ * Extracts the payer address from `credential.source` (DID), calls InsumerAPI
118
+ * to check token/NFT ownership across 32 chains, and returns a free receipt
119
+ * for holders. Non-holders fall through to the original payment method.
120
+ *
121
+ * The attestation is ECDSA P-256 signed and verifiable offline via JWKS.
122
+ *
123
+ * @example
124
+ * ```ts
125
+ * import { tokenGate } from 'mppx-token-gate'
126
+ *
127
+ * const gatedCharge = tokenGate(tempoCharge, {
128
+ * conditions: [{
129
+ * type: 'nft_ownership',
130
+ * contractAddress: '0xYourNFT',
131
+ * chainId: 8453,
132
+ * }],
133
+ * })
134
+ *
135
+ * const mppx = Mppx.create({ methods: [gatedCharge] })
136
+ * ```
137
+ */
138
+ export function tokenGate(server, options) {
139
+ const { conditions, matchMode = 'any', cacheTtlSeconds = 300 } = options;
140
+ const originalVerify = server.verify;
141
+ const gatedVerify = async (params) => {
142
+ const credential = params.credential;
143
+ const source = credential.source;
144
+ // No DID → fall through to payment
145
+ if (!source)
146
+ return originalVerify(params);
147
+ // Determine wallet type and address
148
+ let wallet = null;
149
+ let walletType = 'evm';
150
+ wallet = parseDid(source);
151
+ if (!wallet) {
152
+ wallet = parseSolanaDid(source);
153
+ if (wallet)
154
+ walletType = 'solana';
155
+ }
156
+ if (!wallet) {
157
+ wallet = parsXrplDid(source);
158
+ if (wallet)
159
+ walletType = 'xrpl';
160
+ }
161
+ // Unparseable DID → fall through to payment
162
+ if (!wallet)
163
+ return originalVerify(params);
164
+ // Check cache
165
+ const key = cacheKey(wallet, conditions);
166
+ const cached = cache.get(key);
167
+ if (cached && cached.expiresAt > Date.now()) {
168
+ if (cached.pass) {
169
+ return {
170
+ method: server.name,
171
+ reference: `token-gate:free:${cached.attestationId}`,
172
+ status: 'success',
173
+ timestamp: new Date().toISOString(),
174
+ };
175
+ }
176
+ // Cached non-holder → fall through
177
+ return originalVerify(params);
178
+ }
179
+ // Call InsumerAPI
180
+ try {
181
+ const result = await callAttest(wallet, walletType, conditions, options);
182
+ const attestation = result.data.attestation;
183
+ // Determine pass based on matchMode
184
+ let pass;
185
+ if (matchMode === 'all') {
186
+ pass = attestation.pass; // all conditions must be met
187
+ }
188
+ else {
189
+ // "any" — at least one condition met
190
+ pass = attestation.results.some((r) => r.met);
191
+ }
192
+ // Cache the result
193
+ cache.set(key, {
194
+ pass,
195
+ attestationId: attestation.id,
196
+ expiresAt: Date.now() + cacheTtlSeconds * 1000,
197
+ });
198
+ if (pass) {
199
+ return {
200
+ method: server.name,
201
+ reference: `token-gate:free:${attestation.id}`,
202
+ status: 'success',
203
+ timestamp: new Date().toISOString(),
204
+ };
205
+ }
206
+ }
207
+ catch {
208
+ // Attestation API error → fall through to payment (fail open)
209
+ }
210
+ return originalVerify(params);
211
+ };
212
+ return {
213
+ ...server,
214
+ verify: gatedVerify,
215
+ };
216
+ }
217
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@insumermodel/mppx-token-gate",
3
+ "version": "1.0.0",
4
+ "description": "Token-gate mppx routes using signed wallet attestations — 32 chains, no RPC management",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "keywords": [
23
+ "mppx",
24
+ "mpp",
25
+ "token-gate",
26
+ "token-gating",
27
+ "wallet",
28
+ "attestation",
29
+ "erc20",
30
+ "erc721",
31
+ "nft",
32
+ "solana",
33
+ "xrpl",
34
+ "web3"
35
+ ],
36
+ "author": "douglasborthwick-crypto",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/douglasborthwick-crypto/mppx-token-gate.git"
41
+ },
42
+ "peerDependencies": {
43
+ "mppx": ">=0.1.0"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "mppx": {
47
+ "optional": true
48
+ }
49
+ },
50
+ "devDependencies": {
51
+ "@types/node": "^25.5.0",
52
+ "mppx": "latest",
53
+ "typescript": "^5.7.0"
54
+ }
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,325 @@
1
+ import type { Method, Receipt } from 'mppx'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Types
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export type TokenCondition = {
8
+ /** Token or NFT contract address. For XRPL native XRP, use "native". */
9
+ contractAddress: string
10
+ /**
11
+ * Chain identifier.
12
+ * EVM chain ID (number), "solana", or "xrpl".
13
+ */
14
+ chainId: number | 'solana' | 'xrpl'
15
+ /** "token_balance" or "nft_ownership". */
16
+ type: 'token_balance' | 'nft_ownership'
17
+ /** Minimum balance for token_balance conditions. Defaults to 1. */
18
+ threshold?: number
19
+ /** Token decimals (auto-detected on most EVM chains if omitted). */
20
+ decimals?: number
21
+ /** Human-readable label (max 100 chars). */
22
+ label?: string
23
+ /** XRPL currency code (e.g. "USD", "RLUSD"). Only for XRPL trust-line tokens. */
24
+ currency?: string
25
+ /** XRPL NFT taxon filter. Only for XRPL nft_ownership. */
26
+ taxon?: number
27
+ }
28
+
29
+ export type TokenGateOptions = {
30
+ /** InsumerAPI key. Falls back to INSUMER_API_KEY env var. */
31
+ apiKey?: string
32
+ /** One or more token/NFT conditions to check. */
33
+ conditions: TokenCondition[]
34
+ /** Whether the payer must satisfy "any" (default) or "all" conditions. */
35
+ matchMode?: 'any' | 'all'
36
+ /** In-memory cache TTL in seconds. Defaults to 300 (5 minutes). */
37
+ cacheTtlSeconds?: number
38
+ /**
39
+ * InsumerAPI base URL. Defaults to "https://api.insumermodel.com".
40
+ * Override for testing or self-hosted deployments.
41
+ */
42
+ apiBaseUrl?: string
43
+ /** Request JWT format alongside the raw attestation. Defaults to false. */
44
+ jwt?: boolean
45
+ }
46
+
47
+ export type InsumerAttestation = {
48
+ ok: boolean
49
+ data: {
50
+ attestation: {
51
+ id: string
52
+ pass: boolean
53
+ results: Array<{
54
+ condition: number
55
+ label?: string
56
+ type: string
57
+ chainId: number | string
58
+ met: boolean
59
+ evaluatedCondition: Record<string, unknown>
60
+ conditionHash: string
61
+ blockNumber?: string
62
+ blockTimestamp?: string
63
+ ledgerIndex?: number
64
+ ledgerHash?: string
65
+ }>
66
+ passCount: number
67
+ failCount: number
68
+ attestedAt: string
69
+ expiresAt: string
70
+ }
71
+ sig: string
72
+ kid: string
73
+ jwt?: string
74
+ }
75
+ meta: {
76
+ version: string
77
+ timestamp: string
78
+ creditsRemaining: number
79
+ creditsCharged: number
80
+ }
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Cache
85
+ // ---------------------------------------------------------------------------
86
+
87
+ type CacheEntry = {
88
+ pass: boolean
89
+ attestationId: string
90
+ expiresAt: number
91
+ }
92
+
93
+ const cache = new Map<string, CacheEntry>()
94
+
95
+ function cacheKey(address: string, conditions: TokenCondition[]): string {
96
+ const sorted = [...conditions].sort((a, b) => {
97
+ const ca = `${a.chainId}:${a.contractAddress}`.toLowerCase()
98
+ const cb = `${b.chainId}:${b.contractAddress}`.toLowerCase()
99
+ return ca < cb ? -1 : ca > cb ? 1 : 0
100
+ })
101
+ return `${address.toLowerCase()}:${JSON.stringify(sorted)}`
102
+ }
103
+
104
+ /** Clear the in-memory ownership cache. Useful in tests. */
105
+ export function clearTokenGateCache(): void {
106
+ cache.clear()
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // DID parsing
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /**
114
+ * Extracts an EVM address from a `did:pkh:eip155:{chainId}:{address}` string.
115
+ * Returns null for non-EVM or unparseable DIDs.
116
+ */
117
+ export function parseDid(source: string): `0x${string}` | null {
118
+ const parts = source.split(':')
119
+ if (parts.length !== 5) return null
120
+ if (parts[0] !== 'did' || parts[1] !== 'pkh' || parts[2] !== 'eip155') return null
121
+ const address = parts[4]
122
+ if (!address || !address.startsWith('0x')) return null
123
+ return address as `0x${string}`
124
+ }
125
+
126
+ /**
127
+ * Extracts a Solana address from a `did:pkh:solana:{chainId}:{address}` string.
128
+ */
129
+ export function parseSolanaDid(source: string): string | null {
130
+ const parts = source.split(':')
131
+ if (parts.length !== 5) return null
132
+ if (parts[0] !== 'did' || parts[1] !== 'pkh' || parts[2] !== 'solana') return null
133
+ return parts[4] || null
134
+ }
135
+
136
+ /**
137
+ * Extracts an XRPL address from a `did:pkh:xrpl:{chainId}:{address}` string.
138
+ */
139
+ export function parsXrplDid(source: string): string | null {
140
+ const parts = source.split(':')
141
+ if (parts.length !== 5) return null
142
+ if (parts[0] !== 'did' || parts[1] !== 'pkh' || parts[2] !== 'xrpl') return null
143
+ const address = parts[4]
144
+ if (!address || !address.startsWith('r')) return null
145
+ return address
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // InsumerAPI call
150
+ // ---------------------------------------------------------------------------
151
+
152
+ async function callAttest(
153
+ wallet: string,
154
+ walletType: 'evm' | 'solana' | 'xrpl',
155
+ conditions: TokenCondition[],
156
+ options: Pick<TokenGateOptions, 'apiKey' | 'apiBaseUrl' | 'jwt'>,
157
+ ): Promise<InsumerAttestation> {
158
+ const apiKey = options.apiKey || process.env.INSUMER_API_KEY
159
+ if (!apiKey) {
160
+ throw new Error(
161
+ 'mppx-token-gate: Missing API key. Pass apiKey in options or set INSUMER_API_KEY env var. ' +
162
+ 'Get a free key: POST https://api.insumermodel.com/v1/keys/create',
163
+ )
164
+ }
165
+
166
+ const baseUrl = options.apiBaseUrl || 'https://api.insumermodel.com'
167
+
168
+ const body: Record<string, unknown> = {
169
+ conditions: conditions.map((c) => {
170
+ const cond: Record<string, unknown> = {
171
+ type: c.type,
172
+ contractAddress: c.contractAddress,
173
+ chainId: c.chainId,
174
+ }
175
+ if (c.type === 'token_balance') {
176
+ cond.threshold = c.threshold ?? 1
177
+ }
178
+ if (c.decimals !== undefined) cond.decimals = c.decimals
179
+ if (c.label) cond.label = c.label
180
+ if (c.currency) cond.currency = c.currency
181
+ if (c.taxon !== undefined) cond.taxon = c.taxon
182
+ return cond
183
+ }),
184
+ }
185
+
186
+ if (walletType === 'solana') body.solanaWallet = wallet
187
+ else if (walletType === 'xrpl') body.xrplWallet = wallet
188
+ else body.wallet = wallet
189
+
190
+ if (options.jwt) body.format = 'jwt'
191
+
192
+ const response = await fetch(`${baseUrl}/v1/attest`, {
193
+ method: 'POST',
194
+ headers: {
195
+ 'Content-Type': 'application/json',
196
+ 'x-api-key': apiKey,
197
+ },
198
+ body: JSON.stringify(body),
199
+ })
200
+
201
+ const data = await response.json() as InsumerAttestation
202
+ if (!response.ok || !data.ok) {
203
+ const msg = (data as any)?.error?.message || `HTTP ${response.status}`
204
+ throw new Error(`mppx-token-gate: Attestation failed — ${msg}`)
205
+ }
206
+ return data
207
+ }
208
+
209
+ // ---------------------------------------------------------------------------
210
+ // tokenGate wrapper
211
+ // ---------------------------------------------------------------------------
212
+
213
+ /**
214
+ * Wraps an mppx `Method.Server` to grant free access to token holders.
215
+ *
216
+ * Extracts the payer address from `credential.source` (DID), calls InsumerAPI
217
+ * to check token/NFT ownership across 32 chains, and returns a free receipt
218
+ * for holders. Non-holders fall through to the original payment method.
219
+ *
220
+ * The attestation is ECDSA P-256 signed and verifiable offline via JWKS.
221
+ *
222
+ * @example
223
+ * ```ts
224
+ * import { tokenGate } from 'mppx-token-gate'
225
+ *
226
+ * const gatedCharge = tokenGate(tempoCharge, {
227
+ * conditions: [{
228
+ * type: 'nft_ownership',
229
+ * contractAddress: '0xYourNFT',
230
+ * chainId: 8453,
231
+ * }],
232
+ * })
233
+ *
234
+ * const mppx = Mppx.create({ methods: [gatedCharge] })
235
+ * ```
236
+ */
237
+ export function tokenGate(
238
+ server: Method.AnyServer,
239
+ options: TokenGateOptions,
240
+ ): Method.AnyServer {
241
+ const { conditions, matchMode = 'any', cacheTtlSeconds = 300 } = options
242
+
243
+ const originalVerify = server.verify
244
+
245
+ const gatedVerify: typeof originalVerify = async (params: any) => {
246
+ const credential = params.credential as { source?: string }
247
+ const source = credential.source
248
+
249
+ // No DID → fall through to payment
250
+ if (!source) return originalVerify(params)
251
+
252
+ // Determine wallet type and address
253
+ let wallet: string | null = null
254
+ let walletType: 'evm' | 'solana' | 'xrpl' = 'evm'
255
+
256
+ wallet = parseDid(source)
257
+ if (!wallet) {
258
+ wallet = parseSolanaDid(source)
259
+ if (wallet) walletType = 'solana'
260
+ }
261
+ if (!wallet) {
262
+ wallet = parsXrplDid(source)
263
+ if (wallet) walletType = 'xrpl'
264
+ }
265
+
266
+ // Unparseable DID → fall through to payment
267
+ if (!wallet) return originalVerify(params)
268
+
269
+ // Check cache
270
+ const key = cacheKey(wallet, conditions)
271
+ const cached = cache.get(key)
272
+ if (cached && cached.expiresAt > Date.now()) {
273
+ if (cached.pass) {
274
+ return {
275
+ method: server.name,
276
+ reference: `token-gate:free:${cached.attestationId}`,
277
+ status: 'success' as const,
278
+ timestamp: new Date().toISOString(),
279
+ }
280
+ }
281
+ // Cached non-holder → fall through
282
+ return originalVerify(params)
283
+ }
284
+
285
+ // Call InsumerAPI
286
+ try {
287
+ const result = await callAttest(wallet, walletType, conditions, options)
288
+ const attestation = result.data.attestation
289
+
290
+ // Determine pass based on matchMode
291
+ let pass: boolean
292
+ if (matchMode === 'all') {
293
+ pass = attestation.pass // all conditions must be met
294
+ } else {
295
+ // "any" — at least one condition met
296
+ pass = attestation.results.some((r) => r.met)
297
+ }
298
+
299
+ // Cache the result
300
+ cache.set(key, {
301
+ pass,
302
+ attestationId: attestation.id,
303
+ expiresAt: Date.now() + cacheTtlSeconds * 1000,
304
+ })
305
+
306
+ if (pass) {
307
+ return {
308
+ method: server.name,
309
+ reference: `token-gate:free:${attestation.id}`,
310
+ status: 'success' as const,
311
+ timestamp: new Date().toISOString(),
312
+ }
313
+ }
314
+ } catch {
315
+ // Attestation API error → fall through to payment (fail open)
316
+ }
317
+
318
+ return originalVerify(params)
319
+ }
320
+
321
+ return {
322
+ ...server,
323
+ verify: gatedVerify,
324
+ }
325
+ }