@insumermodel/mppx-condition-gate 3.0.2 → 3.1.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/README.md +80 -16
- package/dist/index.d.ts +88 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +360 -44
- package/dist/index.js.map +1 -1
- package/package.json +5 -9
- package/src/index.ts +440 -60
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Method, Receipt } from 'mppx'
|
|
2
|
+
import { Errors } from 'mppx'
|
|
2
3
|
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
5
|
// Types
|
|
@@ -9,14 +10,18 @@ export type ChainId = number | 'solana' | 'xrpl' | 'bitcoin'
|
|
|
9
10
|
/** ERC-20 / SPL / XRPL trust-line / native balance check */
|
|
10
11
|
export type TokenBalanceCondition = {
|
|
11
12
|
type: 'token_balance'
|
|
12
|
-
/** Token contract address.
|
|
13
|
+
/** Token contract address. Use "native" for the chain's own coin: the EVM native coin
|
|
14
|
+
* (ETH, BNB, ...), XRPL native XRP, SOL or Bitcoin. "native" is for token_balance and
|
|
15
|
+
* ratio_to_amount only; nft_ownership needs the NFT contract address. */
|
|
13
16
|
contractAddress: string
|
|
14
17
|
chainId: ChainId
|
|
15
18
|
/** Minimum balance in token units. Pass a decimal string (e.g. "1000") for
|
|
16
19
|
* full precision; keys minted today sign under the v2 scheme and reject a
|
|
17
20
|
* JSON number, so numbers are converted to strings before sending. Defaults to "1". */
|
|
18
21
|
threshold?: number | string
|
|
19
|
-
/**
|
|
22
|
+
/** Optional. Leave it out: the token's own decimals are always read from the chain.
|
|
23
|
+
* If sent it is only a cross-check, and a value that differs from the token's own
|
|
24
|
+
* decimals is rejected with a 400. */
|
|
20
25
|
decimals?: number
|
|
21
26
|
/** XRPL currency code (e.g. "USD", "RLUSD") for trust-line tokens. */
|
|
22
27
|
currency?: string
|
|
@@ -27,6 +32,7 @@ export type TokenBalanceCondition = {
|
|
|
27
32
|
/** ERC-721 / ERC-1155 / Solana cNFT / XRPL NFT ownership check */
|
|
28
33
|
export type NftOwnershipCondition = {
|
|
29
34
|
type: 'nft_ownership'
|
|
35
|
+
/** NFT contract address (0x + 40 hex on EVM). "native" is not accepted here (400). */
|
|
30
36
|
contractAddress: string
|
|
31
37
|
chainId: ChainId
|
|
32
38
|
/** XRPL NFToken taxon filter. XRPL only. */
|
|
@@ -37,7 +43,7 @@ export type NftOwnershipCondition = {
|
|
|
37
43
|
/** EAS attestation check (Ethereum Attestation Service) */
|
|
38
44
|
export type EasAttestationCondition = {
|
|
39
45
|
type: 'eas_attestation'
|
|
40
|
-
/**
|
|
46
|
+
/** One of the five EAS chains: Ethereum (1), Optimism (10), Polygon (137), Base (8453), Arbitrum (42161). */
|
|
41
47
|
chainId: number
|
|
42
48
|
/**
|
|
43
49
|
* Pre-configured compliance template. Mutually exclusive with schemaId.
|
|
@@ -68,29 +74,33 @@ export type FarcasterIdCondition = {
|
|
|
68
74
|
label?: string
|
|
69
75
|
}
|
|
70
76
|
|
|
71
|
-
/** Self-scaling agent-spend check: met iff balance >= multiple * amount.
|
|
77
|
+
/** Self-scaling agent-spend check: met iff balance >= multiple * amount. EVM chains only. */
|
|
72
78
|
export type RatioToAmountCondition = {
|
|
73
79
|
type: 'ratio_to_amount'
|
|
74
80
|
/** Token contract address, or "native" for the chain's native asset. */
|
|
75
81
|
contractAddress: string
|
|
76
|
-
/** EVM chain ID (ratio conditions are
|
|
82
|
+
/** EVM chain ID (ratio conditions are EVM only). */
|
|
77
83
|
chainId: number
|
|
78
|
-
/** Collateralization multiple. Met iff balance >= multiple * amount.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
amount
|
|
84
|
+
/** Collateralization multiple. Met iff balance >= multiple * amount. Pass a decimal
|
|
85
|
+
* string (e.g. "10"); a number is converted to a decimal string before sending. */
|
|
86
|
+
multiple: string | number
|
|
87
|
+
/** Per-request reference amount in token/display units (e.g. "100" for 100 USDC, not base
|
|
88
|
+
* units), with no more decimal places than the token has. Pass a decimal string; a number
|
|
89
|
+
* is converted to a decimal string before sending. */
|
|
90
|
+
amount: string | number
|
|
82
91
|
label?: string
|
|
83
92
|
}
|
|
84
93
|
|
|
85
|
-
/** Share-of-supply check: met iff balance / totalSupply() >= minFraction.
|
|
94
|
+
/** Share-of-supply check: met iff balance / totalSupply() >= minFraction. EVM chains, ERC-20 only. */
|
|
86
95
|
export type RatioToSupplyCondition = {
|
|
87
96
|
type: 'ratio_to_supply'
|
|
88
97
|
/** ERC-20 token contract address (does not accept "native"). */
|
|
89
98
|
contractAddress: string
|
|
90
|
-
/** EVM chain ID (ratio conditions are
|
|
99
|
+
/** EVM chain ID (ratio conditions are EVM only). */
|
|
91
100
|
chainId: number
|
|
92
|
-
/** Required share of total supply, a fraction in (0, 1] (e.g. 0.005 for 0.5%).
|
|
93
|
-
|
|
101
|
+
/** Required share of total supply, a fraction in (0, 1] (e.g. "0.005" for 0.5%). Pass a
|
|
102
|
+
* decimal string; a number is converted to a decimal string before sending. */
|
|
103
|
+
minFraction: string | number
|
|
94
104
|
label?: string
|
|
95
105
|
}
|
|
96
106
|
|
|
@@ -160,6 +170,44 @@ export type ConditionGateOptions = {
|
|
|
160
170
|
* with `format: "jwt"` directly if you need the `jwt` and `pqJwt` tokens.
|
|
161
171
|
*/
|
|
162
172
|
jwt?: boolean
|
|
173
|
+
/**
|
|
174
|
+
* Where the gate records credentials it has accepted, so each one gets
|
|
175
|
+
* through at most once. Defaults to a bounded in-process store.
|
|
176
|
+
*
|
|
177
|
+
* Pass a shared store when the route runs on more than one instance, or a
|
|
178
|
+
* credential used on one instance can be presented again on another. The
|
|
179
|
+
* store must claim atomically and remove expired entries: `Store.redis`,
|
|
180
|
+
* `Store.upstash` or `Store.cloudflare` from `mppx/server` built from a client
|
|
181
|
+
* that exposes an atomic `update`, or any store with an atomic `tryClaim`.
|
|
182
|
+
* mppx's `Store.memory()` never removes expired entries; use it only in tests.
|
|
183
|
+
*/
|
|
184
|
+
replayStore?: ReplayStore
|
|
185
|
+
/**
|
|
186
|
+
* Longest remaining validity, in seconds, a credential may have and still be
|
|
187
|
+
* given free access. Defaults to 600. The gate has to remember a credential
|
|
188
|
+
* until it expires, and x402 clients choose their own expiry, so longer-lived
|
|
189
|
+
* credentials take the paid path instead.
|
|
190
|
+
*/
|
|
191
|
+
maxCredentialLifetimeSeconds?: number
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* An atomic claim store. Structurally compatible with mppx's `AtomicStore`:
|
|
196
|
+
* `tryClaim` is used when present, otherwise `update`. `get`, when present,
|
|
197
|
+
* lets the gate refuse a repeat before spending an attestation on it, and
|
|
198
|
+
* `delete` (or `update`) lets it release a credential whose payment failed.
|
|
199
|
+
*/
|
|
200
|
+
export type ReplayStore = {
|
|
201
|
+
get?: (key: string) => unknown | Promise<unknown>
|
|
202
|
+
delete?: (key: string) => void | Promise<void>
|
|
203
|
+
tryClaim?: (key: string, expires: number) => boolean | Promise<boolean>
|
|
204
|
+
update?: <result>(
|
|
205
|
+
key: string,
|
|
206
|
+
fn: (current: any) =>
|
|
207
|
+
| { op: 'noop'; result: result }
|
|
208
|
+
| { op: 'set'; value: any; result: result }
|
|
209
|
+
| { op: 'delete'; result: result },
|
|
210
|
+
) => Promise<result>
|
|
163
211
|
}
|
|
164
212
|
|
|
165
213
|
export type InsumerAttestation = {
|
|
@@ -232,9 +280,199 @@ function cacheKey(address: string, conditions: Condition[]): string {
|
|
|
232
280
|
return `${address.toLowerCase()}:${JSON.stringify(sorted)}`
|
|
233
281
|
}
|
|
234
282
|
|
|
235
|
-
/** Clear the in-memory ownership cache. Useful in tests. */
|
|
283
|
+
/** Clear the in-memory ownership cache and the default replay store. Useful in tests. */
|
|
236
284
|
export function clearConditionGateCache(): void {
|
|
237
285
|
cache.clear()
|
|
286
|
+
defaultMarkers.clear()
|
|
287
|
+
freeCountByOwner.clear()
|
|
288
|
+
freeCount = 0
|
|
289
|
+
paidCount = 0
|
|
290
|
+
nextSweepAt = 0
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Single-use credentials
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
// mppx makes a credential single-use when it settles: the on-chain nonce is
|
|
298
|
+
// spent, or the method claims the hash or proof in its store. `validate` is
|
|
299
|
+
// non-mutating by design, so a credential that is granted free access and
|
|
300
|
+
// never settled would otherwise stay usable by anyone who holds a copy.
|
|
301
|
+
|
|
302
|
+
/** Kept this long past the credential's own expiry, to absorb clock skew. */
|
|
303
|
+
const REPLAY_SKEW_MS = 60 * 1000
|
|
304
|
+
const DEFAULT_MAX_CREDENTIAL_LIFETIME_SECONDS = 600
|
|
305
|
+
/** Default store: live free grants across the process, and per wallet. */
|
|
306
|
+
const DEFAULT_STORE_MAX_FREE = 100_000
|
|
307
|
+
const DEFAULT_STORE_MAX_FREE_PER_WALLET = 5_000
|
|
308
|
+
/** Default store: payments it remembers. Past this it stops recording payments. */
|
|
309
|
+
const DEFAULT_STORE_MAX_PAID = 100_000
|
|
310
|
+
const DEFAULT_STORE_SWEEP_MS = 60_000
|
|
311
|
+
|
|
312
|
+
type RecordKind = 'free' | 'paid'
|
|
313
|
+
type DefaultMarker = { expires: number; owner: string; kind: RecordKind }
|
|
314
|
+
|
|
315
|
+
const defaultMarkers = new Map<string, DefaultMarker>()
|
|
316
|
+
const freeCountByOwner = new Map<string, number>()
|
|
317
|
+
let freeCount = 0
|
|
318
|
+
let paidCount = 0
|
|
319
|
+
let nextSweepAt = 0
|
|
320
|
+
|
|
321
|
+
function forgetDefault(key: string): void {
|
|
322
|
+
const marker = defaultMarkers.get(key)
|
|
323
|
+
if (!marker) return
|
|
324
|
+
defaultMarkers.delete(key)
|
|
325
|
+
if (marker.kind === 'paid') {
|
|
326
|
+
paidCount--
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
freeCount--
|
|
330
|
+
const remaining = (freeCountByOwner.get(marker.owner) ?? 1) - 1
|
|
331
|
+
if (remaining <= 0) freeCountByOwner.delete(marker.owner)
|
|
332
|
+
else freeCountByOwner.set(marker.owner, remaining)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** What the gate needs from a store. The default store and a caller's store both sit behind it. */
|
|
336
|
+
type Recorder = {
|
|
337
|
+
isTaken(key: string): Promise<boolean>
|
|
338
|
+
/** true when claimed, false when already taken; throws when it cannot record. */
|
|
339
|
+
claim(key: string, expires: number, owner: string, kind: RecordKind): Promise<boolean>
|
|
340
|
+
release(key: string): Promise<void>
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const defaultRecorder: Recorder = {
|
|
344
|
+
async isTaken(key) {
|
|
345
|
+
const marker = defaultMarkers.get(key)
|
|
346
|
+
return marker !== undefined && marker.expires > Date.now()
|
|
347
|
+
},
|
|
348
|
+
async claim(key, expires, owner, kind) {
|
|
349
|
+
const now = Date.now()
|
|
350
|
+
if (now >= nextSweepAt) {
|
|
351
|
+
for (const [k, marker] of defaultMarkers) if (marker.expires <= now) forgetDefault(k)
|
|
352
|
+
nextSweepAt = now + DEFAULT_STORE_SWEEP_MS
|
|
353
|
+
}
|
|
354
|
+
const current = defaultMarkers.get(key)
|
|
355
|
+
if (current !== undefined) {
|
|
356
|
+
if (current.expires > now) return false
|
|
357
|
+
forgetDefault(key)
|
|
358
|
+
}
|
|
359
|
+
if (kind === 'free') {
|
|
360
|
+
const held = freeCountByOwner.get(owner) ?? 0
|
|
361
|
+
if (freeCount >= DEFAULT_STORE_MAX_FREE || held >= DEFAULT_STORE_MAX_FREE_PER_WALLET) {
|
|
362
|
+
throw new Error('mppx-condition-gate: default replay store has no room for this wallet')
|
|
363
|
+
}
|
|
364
|
+
freeCount++
|
|
365
|
+
freeCountByOwner.set(owner, held + 1)
|
|
366
|
+
} else {
|
|
367
|
+
if (paidCount >= DEFAULT_STORE_MAX_PAID) {
|
|
368
|
+
throw new Error('mppx-condition-gate: default replay store is full')
|
|
369
|
+
}
|
|
370
|
+
paidCount++
|
|
371
|
+
}
|
|
372
|
+
defaultMarkers.set(key, { expires, owner, kind })
|
|
373
|
+
return true
|
|
374
|
+
},
|
|
375
|
+
async release(key) {
|
|
376
|
+
forgetDefault(key)
|
|
377
|
+
},
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function isReplayMarker(value: unknown): value is { expires: number; type: 'mppx:replay' } {
|
|
381
|
+
return (
|
|
382
|
+
typeof value === 'object' &&
|
|
383
|
+
value !== null &&
|
|
384
|
+
typeof (value as { expires?: unknown }).expires === 'number' &&
|
|
385
|
+
(value as { type?: unknown }).type === 'mppx:replay'
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function tryClaim(store: ReplayStore, key: string, expires: number): Promise<boolean> {
|
|
390
|
+
if (typeof store.tryClaim === 'function') return store.tryClaim(key, expires)
|
|
391
|
+
// Same marker as mppx's Store.tryClaim. A live marker, or any other stored
|
|
392
|
+
// value, counts as taken; an empty slot or an expired marker does not.
|
|
393
|
+
return store.update!(key, (current) => {
|
|
394
|
+
const empty = current === null || current === undefined || current === false
|
|
395
|
+
const taken = !empty && (!isReplayMarker(current) || current.expires > Date.now())
|
|
396
|
+
return taken
|
|
397
|
+
? { op: 'noop', result: false }
|
|
398
|
+
: { op: 'set', value: { expires, type: 'mppx:replay' }, result: true }
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function storeRecorder(store: ReplayStore): Recorder {
|
|
403
|
+
return {
|
|
404
|
+
async isTaken(key) {
|
|
405
|
+
// Advisory only: the atomic claim decides. Anything but a live marker reads as free.
|
|
406
|
+
if (typeof store.get !== 'function') return false
|
|
407
|
+
const current = await store.get(key)
|
|
408
|
+
return isReplayMarker(current) && current.expires > Date.now()
|
|
409
|
+
},
|
|
410
|
+
async claim(key, expires) {
|
|
411
|
+
return tryClaim(store, key, expires)
|
|
412
|
+
},
|
|
413
|
+
async release(key) {
|
|
414
|
+
if (typeof store.delete === 'function') {
|
|
415
|
+
await store.delete(key)
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
if (typeof store.update === 'function') {
|
|
419
|
+
await store.update(key, (current) =>
|
|
420
|
+
isReplayMarker(current)
|
|
421
|
+
? { op: 'delete', result: undefined }
|
|
422
|
+
: { op: 'noop', result: undefined },
|
|
423
|
+
)
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function lowerHex(value: unknown): string | null {
|
|
430
|
+
return typeof value === 'string' && /^0x[0-9a-fA-F]+$/.test(value) ? value.toLowerCase() : null
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* One key that identifies the credential, or null when the gate cannot
|
|
435
|
+
* identify it safely, in which case it gets no free access.
|
|
436
|
+
*
|
|
437
|
+
* - EIP-3009 authorization (mppx EVM charge, native and x402): signer + nonce.
|
|
438
|
+
* Neither changes when the signature is re-encoded or the challenge is
|
|
439
|
+
* rebuilt, and two users never share one.
|
|
440
|
+
* - Tempo proof: challenge id + proven payer. The proof signs the challenge
|
|
441
|
+
* id, so it cannot be moved to another challenge.
|
|
442
|
+
*
|
|
443
|
+
* Other credential types are payments in their own right (a Tempo hash is a
|
|
444
|
+
* transfer already on chain, a Tempo transaction is one waiting to be sent),
|
|
445
|
+
* so they take the paid path, where the method's own replay protection applies.
|
|
446
|
+
*/
|
|
447
|
+
function credentialKey(methodName: string, credential: any, payerAddress: string): string | null {
|
|
448
|
+
const payload = credential?.payload
|
|
449
|
+
if (payload === null || typeof payload !== 'object') return null
|
|
450
|
+
let id: string | null = null
|
|
451
|
+
if (payload.type === 'authorization') {
|
|
452
|
+
const from = lowerHex(payload.from)
|
|
453
|
+
const nonce = lowerHex(payload.nonce)
|
|
454
|
+
if (from && nonce) id = `authorization:${from}:${nonce}`
|
|
455
|
+
} else if (payload.type === 'proof') {
|
|
456
|
+
const challengeId = credential?.challenge?.id
|
|
457
|
+
if (typeof challengeId === 'string' && challengeId.length > 0) {
|
|
458
|
+
id = `proof:${challengeId}:${payerAddress.toLowerCase()}`
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return id === null ? null : `mppx-condition-gate:${methodName}:${id}`
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The latest moment the credential could still be accepted, or null if unknown. */
|
|
465
|
+
function credentialExpiry(credential: any): number | null {
|
|
466
|
+
const payload = credential?.payload
|
|
467
|
+
if (payload?.type === 'authorization') {
|
|
468
|
+
// An authorization is bounded by its own validBefore. For x402 the
|
|
469
|
+
// challenge is rebuilt on every request, so its expiry says nothing about
|
|
470
|
+
// the credential; a native credential's validBefore is its challenge's expiry.
|
|
471
|
+
const validBefore = Number(payload.validBefore)
|
|
472
|
+
return Number.isFinite(validBefore) && validBefore > 0 ? validBefore * 1000 : null
|
|
473
|
+
}
|
|
474
|
+
const challengeExpires = Date.parse(credential?.challenge?.expires)
|
|
475
|
+
return Number.isFinite(challengeExpires) ? challengeExpires : null
|
|
238
476
|
}
|
|
239
477
|
|
|
240
478
|
// ---------------------------------------------------------------------------
|
|
@@ -284,6 +522,34 @@ export function parseBitcoinDid(source: string): string | null {
|
|
|
284
522
|
// InsumerAPI call
|
|
285
523
|
// ---------------------------------------------------------------------------
|
|
286
524
|
|
|
525
|
+
/**
|
|
526
|
+
* Returns a quantity as a plain decimal string, the form the API takes for
|
|
527
|
+
* `threshold`, `multiple`, `amount` and `minFraction`.
|
|
528
|
+
*
|
|
529
|
+
* A string is returned trimmed and otherwise unchanged. A number is written
|
|
530
|
+
* without exponent notation: `String(1e-7)` is "1e-7" and `String(1e21)` is
|
|
531
|
+
* "1e+21", neither of which is a decimal string, so those are expanded to
|
|
532
|
+
* "0.0000001" and "1000000000000000000000". NaN and Infinity throw.
|
|
533
|
+
*/
|
|
534
|
+
export function toDecimalString(v: string | number): string {
|
|
535
|
+
if (typeof v === 'string') return v.trim()
|
|
536
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) {
|
|
537
|
+
throw new Error(
|
|
538
|
+
`mppx-condition-gate: a condition quantity must be a decimal string or a finite number, got ${String(v)}.`,
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
const s = String(v)
|
|
542
|
+
const m = /^(-?)(\d+)(?:\.(\d+))?e([+-])(\d+)$/i.exec(s)
|
|
543
|
+
if (!m) return s
|
|
544
|
+
const sign = m[1]
|
|
545
|
+
const digits = m[2] + (m[3] ?? '')
|
|
546
|
+
// Position of the decimal point, counted from the start of `digits`.
|
|
547
|
+
const point = m[2].length + (m[4] === '-' ? -Number(m[5]) : Number(m[5]))
|
|
548
|
+
if (point <= 0) return `${sign}0.${'0'.repeat(-point)}${digits}`
|
|
549
|
+
if (point >= digits.length) return `${sign}${digits}${'0'.repeat(point - digits.length)}`
|
|
550
|
+
return `${sign}${digits.slice(0, point)}.${digits.slice(point)}`
|
|
551
|
+
}
|
|
552
|
+
|
|
287
553
|
function buildBodyConditions(conditions: Condition[]): Array<Record<string, unknown>> {
|
|
288
554
|
return conditions.map((c) => {
|
|
289
555
|
if (c.type === 'farcaster_id') {
|
|
@@ -308,8 +574,8 @@ function buildBodyConditions(conditions: Condition[]): Array<Record<string, unkn
|
|
|
308
574
|
type: c.type,
|
|
309
575
|
contractAddress: c.contractAddress,
|
|
310
576
|
chainId: c.chainId,
|
|
311
|
-
multiple: c.multiple,
|
|
312
|
-
amount: c.amount,
|
|
577
|
+
multiple: toDecimalString(c.multiple),
|
|
578
|
+
amount: toDecimalString(c.amount),
|
|
313
579
|
}
|
|
314
580
|
if (c.label) cond.label = c.label
|
|
315
581
|
return cond
|
|
@@ -319,7 +585,7 @@ function buildBodyConditions(conditions: Condition[]): Array<Record<string, unkn
|
|
|
319
585
|
type: c.type,
|
|
320
586
|
contractAddress: c.contractAddress,
|
|
321
587
|
chainId: c.chainId,
|
|
322
|
-
minFraction: c.minFraction,
|
|
588
|
+
minFraction: toDecimalString(c.minFraction),
|
|
323
589
|
}
|
|
324
590
|
if (c.label) cond.label = c.label
|
|
325
591
|
return cond
|
|
@@ -332,7 +598,7 @@ function buildBodyConditions(conditions: Condition[]): Array<Record<string, unkn
|
|
|
332
598
|
}
|
|
333
599
|
if (c.type === 'token_balance') {
|
|
334
600
|
const threshold = c.threshold ?? '1'
|
|
335
|
-
cond.threshold =
|
|
601
|
+
cond.threshold = toDecimalString(threshold)
|
|
336
602
|
if (c.decimals !== undefined) cond.decimals = c.decimals
|
|
337
603
|
if (c.currency) cond.currency = c.currency
|
|
338
604
|
}
|
|
@@ -438,8 +704,18 @@ export function normalizeProvenPayer(
|
|
|
438
704
|
* credential that fails validation, conditions not met, or an attestation
|
|
439
705
|
* error. There is no path from an unproven wallet to a free receipt.
|
|
440
706
|
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
707
|
+
* Each credential gets free access at most once. The gate records a
|
|
708
|
+
* credential when it grants free access, and records an authorization just
|
|
709
|
+
* before the paid path settles it; a recorded credential is refused with a
|
|
710
|
+
* verification error rather than passed on to payment. A payment that fails is
|
|
711
|
+
* released, so it can be retried. See the README for the limits of this. Free access is given only to credentials the gate can identify
|
|
712
|
+
* (EIP-3009 authorizations and Tempo proofs) whose remaining validity is within
|
|
713
|
+
* `maxCredentialLifetimeSeconds`. The default store is bounded and shared by
|
|
714
|
+
* every gated route in the process; pass a shared `replayStore` for production
|
|
715
|
+
* traffic or when the route runs on more than one instance.
|
|
716
|
+
*
|
|
717
|
+
* Conditions are evaluated across the 34 chains this adapter reaches (31 EVM +
|
|
718
|
+
* Solana + XRPL + Bitcoin; the engine itself covers 37). Six condition types
|
|
443
719
|
* are typed here: token_balance, nft_ownership, eas_attestation, farcaster_id,
|
|
444
720
|
* ratio_to_amount, and ratio_to_supply. InsumerAPI also offers evm_view_call,
|
|
445
721
|
* erc8004_agent, and erc7710_delegation; those are not typed or normalized
|
|
@@ -452,8 +728,9 @@ export function normalizeProvenPayer(
|
|
|
452
728
|
* ```ts
|
|
453
729
|
* import { conditionGate } from '@insumermodel/mppx-condition-gate'
|
|
454
730
|
*
|
|
455
|
-
* const gated = conditionGate(
|
|
731
|
+
* const gated = conditionGate(evmCharge, {
|
|
456
732
|
* // Return only a payer this method has proven for THIS request.
|
|
733
|
+
* // mppx's EVM charge reports the recovered signer as `payer`.
|
|
457
734
|
* provenPayer: (details) => (details as { payer?: string }).payer ?? null,
|
|
458
735
|
* conditions: [
|
|
459
736
|
* { type: 'eas_attestation', template: 'coinbase_verified_account', chainId: 8453 },
|
|
@@ -470,6 +747,25 @@ export function conditionGate(
|
|
|
470
747
|
options: ConditionGateOptions,
|
|
471
748
|
): Method.AnyServer {
|
|
472
749
|
const { conditions, matchMode = 'any', cacheTtlSeconds = 300, provenPayer } = options
|
|
750
|
+
const lifetimeSeconds = options.maxCredentialLifetimeSeconds ?? DEFAULT_MAX_CREDENTIAL_LIFETIME_SECONDS
|
|
751
|
+
if (typeof lifetimeSeconds !== 'number' || !Number.isFinite(lifetimeSeconds) || lifetimeSeconds < 0) {
|
|
752
|
+
throw new Error('mppx-condition-gate: maxCredentialLifetimeSeconds must be a finite number of seconds, 0 or more.')
|
|
753
|
+
}
|
|
754
|
+
const maxLifetimeMs = lifetimeSeconds * 1000
|
|
755
|
+
|
|
756
|
+
// Build the request conditions once up front, so a quantity that is not a
|
|
757
|
+
// finite number is reported here instead of sending every request to the paid path.
|
|
758
|
+
if (Array.isArray(conditions)) buildBodyConditions(conditions)
|
|
759
|
+
|
|
760
|
+
const { replayStore } = options
|
|
761
|
+
if (
|
|
762
|
+
replayStore !== undefined &&
|
|
763
|
+
typeof replayStore.tryClaim !== 'function' &&
|
|
764
|
+
typeof replayStore.update !== 'function'
|
|
765
|
+
) {
|
|
766
|
+
throw new Error('mppx-condition-gate: replayStore must provide an atomic tryClaim or update (an mppx AtomicStore).')
|
|
767
|
+
}
|
|
768
|
+
const recorder = replayStore ? storeRecorder(replayStore) : defaultRecorder
|
|
473
769
|
|
|
474
770
|
const anyServer = server as unknown as {
|
|
475
771
|
verify: (params: any) => Promise<any>
|
|
@@ -488,9 +784,37 @@ export function conditionGate(
|
|
|
488
784
|
const canGate =
|
|
489
785
|
typeof provenPayer === 'function' && typeof originalValidate === 'function'
|
|
490
786
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
787
|
+
const refuse = () =>
|
|
788
|
+
new Errors.VerificationFailedError({ reason: 'this credential has already been presented' })
|
|
789
|
+
|
|
790
|
+
/** Attestation result for a payer, from the cache or a fresh call. Throws on API error. */
|
|
791
|
+
async function evaluate(payer: { address: string; type: WalletType }) {
|
|
792
|
+
const key = cacheKey(payer.address, conditions)
|
|
793
|
+
const cached = cache.get(key)
|
|
794
|
+
if (cached && cached.expiresAt > Date.now()) return cached
|
|
795
|
+
|
|
796
|
+
const result = await callAttest(payer.address, payer.type, conditions, options)
|
|
797
|
+
const attestation = result.data.attestation
|
|
798
|
+
const pass =
|
|
799
|
+
matchMode === 'all'
|
|
800
|
+
? attestation.pass
|
|
801
|
+
: attestation.results.some((r) => r.met)
|
|
802
|
+
const entry = {
|
|
803
|
+
pass,
|
|
804
|
+
attestationId: attestation.id,
|
|
805
|
+
expiresAt: Date.now() + cacheTtlSeconds * 1000,
|
|
806
|
+
}
|
|
807
|
+
cache.set(key, entry)
|
|
808
|
+
return entry
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
type Attempt =
|
|
812
|
+
| { free: true; receipt: any }
|
|
813
|
+
| { free: false; record?: { key: string; expires: number; owner: string; payment: boolean } }
|
|
814
|
+
|
|
815
|
+
/** Decides between a free receipt and the paid path. Throws to refuse a repeat. */
|
|
816
|
+
async function tryFree(params: any): Promise<Attempt> {
|
|
817
|
+
if (!canGate) return { free: false }
|
|
494
818
|
|
|
495
819
|
let details: unknown
|
|
496
820
|
try {
|
|
@@ -498,68 +822,124 @@ export function conditionGate(
|
|
|
498
822
|
details = (validation as { details?: unknown } | undefined)?.details
|
|
499
823
|
} catch {
|
|
500
824
|
// Credential is not currently acceptable → let the payment method decide.
|
|
501
|
-
return
|
|
825
|
+
return { free: false }
|
|
502
826
|
}
|
|
503
827
|
|
|
504
828
|
let resolved: ProvenPayer
|
|
505
829
|
try {
|
|
506
830
|
resolved = await provenPayer!(details)
|
|
507
831
|
} catch {
|
|
508
|
-
return
|
|
832
|
+
return { free: false }
|
|
509
833
|
}
|
|
510
834
|
|
|
511
835
|
const payer = normalizeProvenPayer(resolved)
|
|
512
|
-
if (!payer) return
|
|
836
|
+
if (!payer) return { free: false }
|
|
837
|
+
|
|
838
|
+
const credential = params?.credential
|
|
839
|
+
const key = credentialKey(server.name, credential, payer.address)
|
|
840
|
+
const expiry = credentialExpiry(credential)
|
|
841
|
+
if (key === null || expiry === null) return { free: false }
|
|
842
|
+
|
|
843
|
+
const record = {
|
|
844
|
+
key,
|
|
845
|
+
owner: payer.address.toLowerCase(),
|
|
846
|
+
expires: Math.min(expiry + REPLAY_SKEW_MS, Number.MAX_SAFE_INTEGER),
|
|
847
|
+
// Only an authorization moves value; a Tempo proof is accepted on zero-amount routes.
|
|
848
|
+
payment: credential?.payload?.type === 'authorization',
|
|
849
|
+
}
|
|
513
850
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
851
|
+
// A recorded credential is refused before any attestation is spent on it.
|
|
852
|
+
let taken: boolean
|
|
853
|
+
try {
|
|
854
|
+
taken = await recorder.isTaken(key)
|
|
855
|
+
} catch {
|
|
856
|
+
return { free: false, record }
|
|
857
|
+
}
|
|
858
|
+
if (taken) {
|
|
859
|
+
// A repeated Tempo proof moves no value, so the method decides; a payment is refused.
|
|
860
|
+
if (!record.payment) return { free: false }
|
|
861
|
+
throw refuse()
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (expiry - Date.now() > maxLifetimeMs) return { free: false, record }
|
|
865
|
+
|
|
866
|
+
let evaluation: CacheEntry
|
|
867
|
+
try {
|
|
868
|
+
evaluation = await evaluate(payer)
|
|
869
|
+
} catch {
|
|
870
|
+
// Attestation error → paid path.
|
|
871
|
+
return { free: false, record }
|
|
872
|
+
}
|
|
873
|
+
if (!evaluation.pass) return { free: false, record }
|
|
874
|
+
|
|
875
|
+
let claimed: boolean
|
|
876
|
+
try {
|
|
877
|
+
claimed = await recorder.claim(key, record.expires, record.owner, 'free')
|
|
878
|
+
} catch {
|
|
879
|
+
// The store cannot record the credential, so it gets no free access.
|
|
880
|
+
return { free: false, record }
|
|
881
|
+
}
|
|
882
|
+
if (!claimed) {
|
|
883
|
+
if (!record.payment) return { free: false }
|
|
884
|
+
throw refuse()
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return {
|
|
888
|
+
free: true,
|
|
889
|
+
receipt: {
|
|
519
890
|
method: server.name,
|
|
520
|
-
reference: `condition-gate:free:${
|
|
891
|
+
reference: `condition-gate:free:${evaluation.attestationId}`,
|
|
521
892
|
status: 'success' as const,
|
|
522
893
|
timestamp: new Date().toISOString(),
|
|
894
|
+
},
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Runs the gate, then the original method on the paid path. */
|
|
899
|
+
async function gated(original: (params: any) => Promise<any>, params: any) {
|
|
900
|
+
const attempt = await tryFree(params)
|
|
901
|
+
if (attempt.free) return attempt.receipt
|
|
902
|
+
|
|
903
|
+
const record = attempt.record
|
|
904
|
+
let claimed = false
|
|
905
|
+
// Only a payment is recorded on this path. A Tempo proof moves no value, so
|
|
906
|
+
// the method's own rules decide whether it may be presented again.
|
|
907
|
+
if (record && record.payment) {
|
|
908
|
+
// Claimed before settlement, so a copy presented while this payment is
|
|
909
|
+
// in flight is refused too. Kept until the credential itself expires.
|
|
910
|
+
let result: boolean | null
|
|
911
|
+
try {
|
|
912
|
+
result = await recorder.claim(record.key, record.expires, record.owner, 'paid')
|
|
913
|
+
} catch {
|
|
914
|
+
// Cannot record: pay as bare mppx would; the method's own replay protection applies.
|
|
915
|
+
result = null
|
|
523
916
|
}
|
|
917
|
+
if (result === false) throw refuse()
|
|
918
|
+
claimed = result === true
|
|
524
919
|
}
|
|
525
920
|
|
|
526
921
|
try {
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
pass,
|
|
537
|
-
attestationId: attestation.id,
|
|
538
|
-
expiresAt: Date.now() + cacheTtlSeconds * 1000,
|
|
539
|
-
})
|
|
540
|
-
|
|
541
|
-
if (!pass) return null
|
|
542
|
-
return {
|
|
543
|
-
method: server.name,
|
|
544
|
-
reference: `condition-gate:free:${attestation.id}`,
|
|
545
|
-
status: 'success' as const,
|
|
546
|
-
timestamp: new Date().toISOString(),
|
|
922
|
+
return await original(params)
|
|
923
|
+
} catch (error) {
|
|
924
|
+
// A payment that fails is released, so the holder can retry with the same credential.
|
|
925
|
+
if (claimed) {
|
|
926
|
+
try {
|
|
927
|
+
await recorder.release(record!.key)
|
|
928
|
+
} catch {
|
|
929
|
+
// Stays recorded until it expires.
|
|
930
|
+
}
|
|
547
931
|
}
|
|
548
|
-
|
|
549
|
-
// Attestation error → paid path.
|
|
550
|
-
return null
|
|
932
|
+
throw error
|
|
551
933
|
}
|
|
552
934
|
}
|
|
553
935
|
|
|
554
936
|
const wrapped = { ...server } as unknown as Record<string, unknown>
|
|
555
937
|
|
|
556
938
|
if (typeof originalBroadcast === 'function') {
|
|
557
|
-
wrapped.broadcast = async (params: any) =>
|
|
558
|
-
(await tryFree(params)) ?? originalBroadcast(params)
|
|
939
|
+
wrapped.broadcast = async (params: any) => gated(originalBroadcast, params)
|
|
559
940
|
}
|
|
560
941
|
|
|
561
|
-
wrapped.verify = async (params: any) =>
|
|
562
|
-
(await tryFree(params)) ?? originalVerify(params)
|
|
942
|
+
wrapped.verify = async (params: any) => gated(originalVerify, params)
|
|
563
943
|
|
|
564
944
|
return wrapped as unknown as Method.AnyServer
|
|
565
945
|
}
|