@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/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. For XRPL native XRP or Bitcoin, use "native". */
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
- /** Token decimals. Auto-detected on most EVM chains if omitted. */
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
- /** EVM chain ID (typically Base 8453). */
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. RPC EVM chains only. */
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 RPC EVM only). */
82
+ /** EVM chain ID (ratio conditions are EVM only). */
77
83
  chainId: number
78
- /** Collateralization multiple. Met iff balance >= multiple * amount. */
79
- multiple: number
80
- /** Per-request reference amount in token/display units (e.g. 100 for 100 USDC, not base units). */
81
- amount: number
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. RPC EVM + ERC-20 only. */
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 RPC EVM only). */
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
- minFraction: number
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 = typeof threshold === 'string' ? threshold : String(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
- * Conditions are evaluated across the 35 chains this adapter reaches (32 EVM +
442
- * Solana + XRPL + Bitcoin; the engine itself covers 38). Six condition types
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(tempoCharge, {
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
- /** Returns a free receipt, or null to take the paid path. */
492
- async function tryFree(params: any): Promise<any | null> {
493
- if (!canGate) return null
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 null
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 null
832
+ return { free: false }
509
833
  }
510
834
 
511
835
  const payer = normalizeProvenPayer(resolved)
512
- if (!payer) return null
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
- const key = cacheKey(payer.address, conditions)
515
- const cached = cache.get(key)
516
- if (cached && cached.expiresAt > Date.now()) {
517
- if (!cached.pass) return null
518
- return {
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:${cached.attestationId}`,
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
- const result = await callAttest(payer.address, payer.type, conditions, options)
528
- const attestation = result.data.attestation
529
-
530
- const pass =
531
- matchMode === 'all'
532
- ? attestation.pass
533
- : attestation.results.some((r) => r.met)
534
-
535
- cache.set(key, {
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
- } catch {
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
  }