@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 +108 -0
- package/dist/index.d.ts +115 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +217 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
- package/src/index.ts +325 -0
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
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|