@pafi-dev/issuer 0.7.8 → 0.8.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/dist/index.cjs CHANGED
@@ -28,6 +28,7 @@ __export(index_exports, {
28
28
  BundlerRejectedError: () => BundlerRejectedError,
29
29
  BurnIndexer: () => BurnIndexer,
30
30
  ConfigurationError: () => ConfigurationError,
31
+ DEFAULT_REDEMPTION_POLICY: () => DEFAULT_REDEMPTION_POLICY,
31
32
  DefaultPolicyEngine: () => DefaultPolicyEngine,
32
33
  FeeManager: () => FeeManager,
33
34
  InMemoryCursorStore: () => InMemoryCursorStore,
@@ -37,8 +38,11 @@ __export(index_exports, {
37
38
  IssuerStateValidator: () => IssuerStateValidator,
38
39
  LockNotFoundError: () => LockNotFoundError,
39
40
  MemoryPendingUserOpStore: () => MemoryPendingUserOpStore,
41
+ MemoryRateLimiter: () => MemoryRateLimiter,
42
+ MemoryRedemptionHistoryStore: () => MemoryRedemptionHistoryStore,
40
43
  MemorySessionStore: () => MemorySessionStore,
41
44
  NonceManager: () => NonceManager,
45
+ NoopRateLimiter: () => NoopRateLimiter,
42
46
  PAFI_ISSUER_SDK_VERSION: () => PAFI_ISSUER_SDK_VERSION,
43
47
  PAFI_SUBGRAPH_URL: () => import_core15.PAFI_SUBGRAPH_URL,
44
48
  PTClaimError: () => PTClaimError,
@@ -53,8 +57,12 @@ __export(index_exports, {
53
57
  PerpDepositError: () => PerpDepositError,
54
58
  PerpDepositHandler: () => PerpDepositHandler,
55
59
  PointIndexer: () => PointIndexer,
60
+ PolicyProvider: () => PolicyProvider,
61
+ REDEMPTION_HISTORY_WINDOW_SEC: () => REDEMPTION_HISTORY_WINDOW_SEC,
62
+ RedemptionService: () => RedemptionService,
56
63
  RelayError: () => RelayError,
57
64
  RelayService: () => RelayService,
65
+ SettlementClient: () => SettlementClient,
58
66
  ValidationError: () => import_core3.ValidationError,
59
67
  authenticateRequest: () => authenticateRequest,
60
68
  createIssuerService: () => createIssuerService,
@@ -62,6 +70,8 @@ __export(index_exports, {
62
70
  createSdkErrorMapper: () => createSdkErrorMapper,
63
71
  createSubgraphNativeUsdtQuoter: () => createSubgraphNativeUsdtQuoter,
64
72
  createSubgraphPoolsProvider: () => createSubgraphPoolsProvider,
73
+ defaultPolicyFor: () => defaultPolicyFor,
74
+ evaluateRedemption: () => evaluateRedemption,
65
75
  handleClaimStatus: () => handleClaimStatus,
66
76
  handleDelegateSubmit: () => handleDelegateSubmit,
67
77
  handleMobilePrepare: () => handleMobilePrepare,
@@ -161,9 +171,9 @@ var MemorySessionStore = class {
161
171
  nonceTtlMs;
162
172
  now;
163
173
  constructor(opts = {}) {
164
- if (process.env.NODE_ENV === "production") {
165
- console.error(
166
- "[PAFI] MemorySessionStore is not safe for multi-process/K8s deployments. Session revocations are NOT propagated across pods. Use a Redis-backed session store in production."
174
+ if (process.env.NODE_ENV === "production" && !opts.dangerouslyAllowMemoryStoreInProduction) {
175
+ throw new Error(
176
+ "[PAFI] MemorySessionStore refuses to start in production (NODE_ENV=production). Multi-pod K8s deploys do not share Map state, so sessions are not revocable across replicas \u2014 `logout` on pod A leaves the token valid on pod B until expiry. Use a Redis-backed session store (see RedisSessionStoreService in gg56-backend or implement your own ISessionStore). To bypass for a single-pod deploy, pass `dangerouslyAllowMemoryStoreInProduction: true`."
167
177
  );
168
178
  }
169
179
  this.nonceTtlMs = opts.nonceTtlMs ?? DEFAULT_NONCE_TTL_MS;
@@ -290,6 +300,64 @@ var AuthError = class extends import_core.PafiSdkError {
290
300
  };
291
301
 
292
302
  // src/auth/loginVerifier.ts
303
+ function assertJwtSecretStrength(secret) {
304
+ if (!secret) {
305
+ throw new Error(
306
+ "AuthService: jwtSecret is required. Generate via `node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"`"
307
+ );
308
+ }
309
+ if (secret.length < 32) {
310
+ throw new Error(
311
+ `AuthService: jwtSecret too short (${secret.length} chars; need \u2265 32). HS256 brute-force becomes feasible below this threshold.`
312
+ );
313
+ }
314
+ const uniqueChars = new Set(secret).size;
315
+ if (uniqueChars < 16) {
316
+ throw new Error(
317
+ `AuthService: jwtSecret has only ${uniqueChars} unique characters; need \u2265 16. A trivially weak secret (e.g. "aaaaa...") will pass length but offer almost no entropy. Use \`crypto.randomBytes(32).toString('hex')\`.`
318
+ );
319
+ }
320
+ const entropy = shannonEntropyBitsPerChar(secret);
321
+ if (entropy < 3.5) {
322
+ throw new Error(
323
+ `AuthService: jwtSecret entropy too low (${entropy.toFixed(2)} bits/char; need \u2265 3.5). Use \`crypto.randomBytes(32).toString('hex')\` (\u2248 4.0 bits/char).`
324
+ );
325
+ }
326
+ for (let period = 1; period <= secret.length / 4; period++) {
327
+ if (secret.length % period !== 0) continue;
328
+ const head = secret.slice(0, period);
329
+ if (secret === head.repeat(secret.length / period)) {
330
+ throw new Error(
331
+ `AuthService: jwtSecret is a repeating pattern of period ${period}. Use \`crypto.randomBytes(32).toString('hex')\`.`
332
+ );
333
+ }
334
+ }
335
+ }
336
+ function shannonEntropyBitsPerChar(s) {
337
+ const counts = /* @__PURE__ */ new Map();
338
+ for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1);
339
+ const len = s.length;
340
+ let h = 0;
341
+ for (const n of counts.values()) {
342
+ const p = n / len;
343
+ h -= p * Math.log2(p);
344
+ }
345
+ return h;
346
+ }
347
+ function decodeExpiredJwtJti(token) {
348
+ try {
349
+ const parts = token.split(".");
350
+ if (parts.length !== 3) return {};
351
+ const payloadB64 = parts[1];
352
+ if (!payloadB64) return {};
353
+ const padded = payloadB64.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((payloadB64.length + 3) % 4);
354
+ const json = Buffer.from(padded, "base64").toString("utf-8");
355
+ const claims = JSON.parse(json);
356
+ return typeof claims.jti === "string" ? { jti: claims.jti } : {};
357
+ } catch {
358
+ return {};
359
+ }
360
+ }
293
361
  var DEFAULT_EXPIRES_IN = "24h";
294
362
  var AuthService = class {
295
363
  sessionStore;
@@ -297,17 +365,19 @@ var AuthService = class {
297
365
  jwtExpiresIn;
298
366
  domain;
299
367
  chainId;
368
+ issuer;
369
+ audience;
300
370
  nonceManager;
301
371
  now;
302
372
  constructor(config) {
303
- if (!config.jwtSecret || config.jwtSecret.length < 32) {
304
- throw new Error("AuthService: jwtSecret must be at least 32 characters for HS256 security");
305
- }
373
+ assertJwtSecretStrength(config.jwtSecret);
306
374
  this.sessionStore = config.sessionStore;
307
375
  this.jwtSecret = new TextEncoder().encode(config.jwtSecret);
308
376
  this.jwtExpiresIn = config.jwtExpiresIn ?? DEFAULT_EXPIRES_IN;
309
377
  this.domain = config.domain;
310
378
  this.chainId = config.chainId;
379
+ this.issuer = config.issuer;
380
+ this.audience = config.audience;
311
381
  this.nonceManager = new NonceManager(config.sessionStore);
312
382
  this.now = config.now ?? (() => /* @__PURE__ */ new Date());
313
383
  }
@@ -384,29 +454,59 @@ var AuthService = class {
384
454
  expiresAt
385
455
  };
386
456
  await this.sessionStore.createSession(session);
387
- const token = await new import_jose.SignJWT({
457
+ let signer = new import_jose.SignJWT({
388
458
  userAddress,
389
459
  chainId: this.chainId
390
- }).setProtectedHeader({ alg: "HS256" }).setJti(tokenId).setIssuedAt(Math.floor(issuedAt.getTime() / 1e3)).setExpirationTime(Math.floor(expiresAt.getTime() / 1e3)).sign(this.jwtSecret);
460
+ }).setProtectedHeader({ alg: "HS256" }).setJti(tokenId).setIssuedAt(Math.floor(issuedAt.getTime() / 1e3)).setExpirationTime(Math.floor(expiresAt.getTime() / 1e3));
461
+ if (this.issuer) signer = signer.setIssuer(this.issuer);
462
+ if (this.audience) signer = signer.setAudience(this.audience);
463
+ const token = await signer.sign(this.jwtSecret);
391
464
  return { token, userAddress, tokenId, expiresAt };
392
465
  }
393
466
  /** Revoke the session backing the given JWT (logout). */
394
467
  async logout(token) {
468
+ let payload;
395
469
  try {
396
- const { payload } = await (0, import_jose.jwtVerify)(token, this.jwtSecret, {
397
- clockTolerance: 60
470
+ const result = await (0, import_jose.jwtVerify)(token, this.jwtSecret, {
471
+ clockTolerance: 60,
398
472
  // allow logout right after expiry
473
+ ...this.issuer ? { issuer: this.issuer } : {},
474
+ ...this.audience ? { audience: this.audience } : {}
399
475
  });
400
- if (payload.jti) {
401
- await this.sessionStore.revokeSession(payload.jti);
402
- }
476
+ payload = result.payload;
403
477
  } catch (err) {
404
- const msg = err instanceof Error ? err.message : String(err);
405
- if (!msg.includes("not found") && !msg.includes("expired")) {
406
- console.error("[PAFI] AuthService logout: session store error", err);
478
+ if (err instanceof import_jose.errors.JWTExpired) {
479
+ const decoded = decodeExpiredJwtJti(token);
480
+ if (decoded.jti) {
481
+ try {
482
+ await this.sessionStore.revokeSession(decoded.jti);
483
+ } catch (storeErr) {
484
+ this.logSessionStoreError(storeErr);
485
+ }
486
+ }
487
+ return;
488
+ }
489
+ if (err instanceof import_jose.errors.JWSSignatureVerificationFailed || err instanceof import_jose.errors.JWSInvalid || err instanceof import_jose.errors.JWTInvalid) {
490
+ throw new AuthError("TOKEN_INVALID", "JWT verification failed");
491
+ }
492
+ throw new AuthError(
493
+ "TOKEN_INVALID",
494
+ `JWT verification failed: ${err instanceof Error ? err.message : String(err)}`
495
+ );
496
+ }
497
+ if (payload.jti) {
498
+ try {
499
+ await this.sessionStore.revokeSession(payload.jti);
500
+ } catch (storeErr) {
501
+ this.logSessionStoreError(storeErr);
407
502
  }
408
503
  }
409
504
  }
505
+ logSessionStoreError(err) {
506
+ const msg = err instanceof Error ? err.message : String(err);
507
+ if (msg.includes("not found")) return;
508
+ console.error("[PAFI] AuthService logout: session store error", err);
509
+ }
410
510
  /**
411
511
  * Verify a JWT and return the authenticated user context. Throws an
412
512
  * `AuthError` if the token is missing, malformed, expired, revoked, or
@@ -415,7 +515,10 @@ var AuthService = class {
415
515
  async verifyToken(token) {
416
516
  let payload;
417
517
  try {
418
- const result = await (0, import_jose.jwtVerify)(token, this.jwtSecret);
518
+ const result = await (0, import_jose.jwtVerify)(token, this.jwtSecret, {
519
+ ...this.issuer ? { issuer: this.issuer } : {},
520
+ ...this.audience ? { audience: this.audience } : {}
521
+ });
419
522
  payload = result.payload;
420
523
  } catch (err) {
421
524
  if (err instanceof import_jose.errors.JWTExpired) {
@@ -481,6 +584,70 @@ async function authenticateRequest(authHeader, authService) {
481
584
  return authService.verifyToken(token);
482
585
  }
483
586
 
587
+ // src/auth/rateLimiter.ts
588
+ var DEFAULT_LIMITS = {
589
+ auth_nonce: { max: 30, windowMs: 6e4 },
590
+ // 30 nonces/min ≈ 1 per 2s
591
+ auth_login: { max: 5, windowMs: 6e4 }
592
+ // 5 logins/min
593
+ };
594
+ var MemoryRateLimiter = class {
595
+ buckets = /* @__PURE__ */ new Map();
596
+ limits;
597
+ now;
598
+ constructor(config = {}) {
599
+ if (process.env.NODE_ENV === "production" && !process.env.PAFI_ALLOW_MEMORY_RATE_LIMITER_IN_PROD) {
600
+ console.warn(
601
+ "[PAFI] MemoryRateLimiter not safe for multi-pod K8s deploys \u2014 rate counters are NOT shared across replicas, allowing round-robin bypass. Use a Redis-backed IRateLimiter in production."
602
+ );
603
+ }
604
+ this.limits = {
605
+ ...DEFAULT_LIMITS,
606
+ ...config.limits ?? {}
607
+ };
608
+ this.now = config.now ?? (() => Date.now());
609
+ }
610
+ async consume(key, action) {
611
+ const limit = this.limits[action];
612
+ if (!limit) return { allowed: true };
613
+ const bucketKey = `${action}:${key}`;
614
+ const now = this.now();
615
+ const bucket = this.buckets.get(bucketKey);
616
+ if (!bucket || now - bucket.windowStartedAt >= limit.windowMs) {
617
+ this.buckets.set(bucketKey, { count: 1, windowStartedAt: now });
618
+ return { allowed: true };
619
+ }
620
+ if (bucket.count < limit.max) {
621
+ bucket.count += 1;
622
+ return { allowed: true };
623
+ }
624
+ const retryAfterMs = Math.max(
625
+ 0,
626
+ bucket.windowStartedAt + limit.windowMs - now
627
+ );
628
+ return { allowed: false, retryAfterMs };
629
+ }
630
+ /**
631
+ * Test helper — clear all buckets. Not part of `IRateLimiter`; only
632
+ * exposed on the in-memory impl for unit tests.
633
+ */
634
+ reset() {
635
+ this.buckets.clear();
636
+ }
637
+ };
638
+ var NoopRateLimiter = class {
639
+ warned = false;
640
+ async consume() {
641
+ if (!this.warned && process.env.NODE_ENV === "production") {
642
+ console.warn(
643
+ "[PAFI] NoopRateLimiter active \u2014 `/auth/nonce` and `/auth/login` are NOT throttled. Wire a `MemoryRateLimiter` (dev) or Redis-backed impl (prod) via `IssuerApiHandlersConfig.rateLimiter`."
644
+ );
645
+ this.warned = true;
646
+ }
647
+ return { allowed: true };
648
+ }
649
+ };
650
+
484
651
  // src/relay/types.ts
485
652
  var RelayError = class extends import_core.PafiSdkError {
486
653
  httpStatus = "unprocessable";
@@ -828,6 +995,7 @@ var PointIndexer = class {
828
995
  confirmations;
829
996
  batchSize;
830
997
  pollIntervalMs;
998
+ onTickError;
831
999
  running = false;
832
1000
  timer;
833
1001
  constructor(config) {
@@ -843,6 +1011,7 @@ var PointIndexer = class {
843
1011
  this.confirmations = BigInt(config.confirmations ?? DEFAULT_CONFIRMATIONS);
844
1012
  this.batchSize = BigInt(config.batchSize ?? Number(DEFAULT_BATCH_SIZE));
845
1013
  this.pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1014
+ if (config.onTickError) this.onTickError = config.onTickError;
846
1015
  }
847
1016
  // -------------------------------------------------------------------------
848
1017
  // Lifecycle
@@ -851,7 +1020,7 @@ var PointIndexer = class {
851
1020
  start() {
852
1021
  if (this.running) return;
853
1022
  this.running = true;
854
- void this.tick();
1023
+ this.tick().catch((err) => this.handleTickError(err));
855
1024
  }
856
1025
  /** Stop polling. Safe to call multiple times. */
857
1026
  stop() {
@@ -883,13 +1052,27 @@ var PointIndexer = class {
883
1052
  }
884
1053
  await this.processBlockRange(from, safeHead);
885
1054
  } catch (err) {
886
- console.error("[PAFI] PointIndexer tick error:", err);
1055
+ this.handleTickError(err);
887
1056
  }
888
1057
  this.scheduleNext();
889
1058
  }
1059
+ handleTickError(err) {
1060
+ if (this.onTickError) {
1061
+ try {
1062
+ this.onTickError(err);
1063
+ } catch {
1064
+ console.error("[PAFI] PointIndexer onTickError threw:", err);
1065
+ }
1066
+ } else {
1067
+ console.error("[PAFI] PointIndexer tick error:", err);
1068
+ }
1069
+ }
890
1070
  scheduleNext() {
891
1071
  if (!this.running) return;
892
- this.timer = setTimeout(() => void this.tick(), this.pollIntervalMs);
1072
+ this.timer = setTimeout(
1073
+ () => this.tick().catch((err) => this.handleTickError(err)),
1074
+ this.pollIntervalMs
1075
+ );
893
1076
  }
894
1077
  // -------------------------------------------------------------------------
895
1078
  // Block scanning
@@ -1009,6 +1192,7 @@ var BurnIndexer = class {
1009
1192
  confirmations;
1010
1193
  batchSize;
1011
1194
  pollIntervalMs;
1195
+ onTickError;
1012
1196
  matchLockId;
1013
1197
  running = false;
1014
1198
  timer;
@@ -1027,6 +1211,7 @@ var BurnIndexer = class {
1027
1211
  );
1028
1212
  this.batchSize = BigInt(config.batchSize ?? Number(DEFAULT_BATCH_SIZE2));
1029
1213
  this.pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS2;
1214
+ if (config.onTickError) this.onTickError = config.onTickError;
1030
1215
  if (!config.matchLockId) {
1031
1216
  throw new Error(
1032
1217
  "BurnIndexer: matchLockId is required. Provide a function that maps a burn event to its pending credit lockId. Without it, no on-chain burns will ever grant off-chain credits."
@@ -1037,7 +1222,7 @@ var BurnIndexer = class {
1037
1222
  start() {
1038
1223
  if (this.running) return;
1039
1224
  this.running = true;
1040
- void this.tick();
1225
+ this.tick().catch((err) => this.handleTickError(err));
1041
1226
  }
1042
1227
  stop() {
1043
1228
  this.running = false;
@@ -1063,13 +1248,27 @@ var BurnIndexer = class {
1063
1248
  }
1064
1249
  await this.processBlockRange(from, safeHead);
1065
1250
  } catch (err) {
1066
- console.error("[PAFI] BurnIndexer tick error:", err);
1251
+ this.handleTickError(err);
1067
1252
  }
1068
1253
  this.scheduleNext();
1069
1254
  }
1255
+ handleTickError(err) {
1256
+ if (this.onTickError) {
1257
+ try {
1258
+ this.onTickError(err);
1259
+ } catch {
1260
+ console.error("[PAFI] BurnIndexer onTickError threw:", err);
1261
+ }
1262
+ } else {
1263
+ console.error("[PAFI] BurnIndexer tick error:", err);
1264
+ }
1265
+ }
1070
1266
  scheduleNext() {
1071
1267
  if (!this.running) return;
1072
- this.timer = setTimeout(() => void this.tick(), this.pollIntervalMs);
1268
+ this.timer = setTimeout(
1269
+ () => this.tick().catch((err) => this.handleTickError(err)),
1270
+ this.pollIntervalMs
1271
+ );
1073
1272
  }
1074
1273
  /**
1075
1274
  * Scan `[from, to]` inclusive for burn events. Callers can drive this
@@ -1162,10 +1361,13 @@ var IssuerApiHandlers = class {
1162
1361
  pafiWebUrl;
1163
1362
  feeManager;
1164
1363
  poolsProvider;
1364
+ redemption;
1365
+ rateLimiter;
1165
1366
  constructor(config) {
1166
1367
  this.authService = config.authService;
1167
1368
  this.ledger = config.ledger;
1168
1369
  this.provider = config.provider;
1370
+ this.rateLimiter = config.rateLimiter ?? new NoopRateLimiter();
1169
1371
  const raw = config.pointTokenAddresses && config.pointTokenAddresses.length > 0 ? config.pointTokenAddresses : config.pointTokenAddress ? [config.pointTokenAddress] : [];
1170
1372
  if (raw.length === 0) {
1171
1373
  throw new Error(
@@ -1179,17 +1381,64 @@ var IssuerApiHandlers = class {
1179
1381
  if (config.pafiWebUrl) this.pafiWebUrl = config.pafiWebUrl;
1180
1382
  if (config.feeManager) this.feeManager = config.feeManager;
1181
1383
  if (config.poolsProvider) this.poolsProvider = config.poolsProvider;
1384
+ if (config.redemption) this.redemption = config.redemption;
1182
1385
  }
1183
1386
  // =========================================================================
1184
1387
  // Public handlers (no auth required)
1185
1388
  // =========================================================================
1186
- /** `GET /auth/nonce` */
1187
- async handleGetNonce() {
1389
+ /**
1390
+ * `GET /auth/nonce`
1391
+ *
1392
+ * @param rateLimitKey Caller-side rate-limit key (typically client IP).
1393
+ * The HTTP layer (controller/middleware) extracts
1394
+ * this from the request and passes it through.
1395
+ * When omitted, no rate limit applies — production
1396
+ * callers SHOULD always pass a key.
1397
+ */
1398
+ async handleGetNonce(rateLimitKey) {
1399
+ if (rateLimitKey) {
1400
+ const result = await this.rateLimiter.consume(
1401
+ rateLimitKey,
1402
+ "auth_nonce"
1403
+ );
1404
+ if (!result.allowed) {
1405
+ throw new import_core3.ValidationError(
1406
+ "RATE_LIMIT_EXCEEDED",
1407
+ "handleGetNonce: too many requests",
1408
+ {
1409
+ retryAfterMs: result.retryAfterMs ?? 0,
1410
+ action: "auth_nonce"
1411
+ }
1412
+ );
1413
+ }
1414
+ }
1188
1415
  const nonce = await this.authService.getNonce();
1189
1416
  return { nonce };
1190
1417
  }
1191
- /** `POST /auth/login` */
1192
- async handleLogin(body) {
1418
+ /**
1419
+ * `POST /auth/login`
1420
+ *
1421
+ * @param body Login message + signature.
1422
+ * @param rateLimitKey Caller-side rate-limit key (typically client IP
1423
+ * or `body.userAddress` if known). See `handleGetNonce`.
1424
+ */
1425
+ async handleLogin(body, rateLimitKey) {
1426
+ if (rateLimitKey) {
1427
+ const result2 = await this.rateLimiter.consume(
1428
+ rateLimitKey,
1429
+ "auth_login"
1430
+ );
1431
+ if (!result2.allowed) {
1432
+ throw new import_core3.ValidationError(
1433
+ "RATE_LIMIT_EXCEEDED",
1434
+ "handleLogin: too many requests",
1435
+ {
1436
+ retryAfterMs: result2.retryAfterMs ?? 0,
1437
+ action: "auth_login"
1438
+ }
1439
+ );
1440
+ }
1441
+ }
1193
1442
  if (!body || typeof body.message !== "string" || body.message.length === 0 || typeof body.signature !== "string" || body.signature.length <= 2) {
1194
1443
  throw new import_core3.ValidationError(
1195
1444
  "INVALID_LOGIN_BODY",
@@ -1332,6 +1581,74 @@ var IssuerApiHandlers = class {
1332
1581
  // Note: legacy `handleClaim` (sync sponsored-claim returning calls[]) was
1333
1582
  // removed in 0.5.43 — callers should use `PTClaimHandler` directly or
1334
1583
  // wire `IssuerApiAdapter.claim()` which composes the full flow.
1584
+ /**
1585
+ * `GET /redemption/preview?pointToken=<addr>`
1586
+ *
1587
+ * Returns the headroom currently available to `userAddress` under the
1588
+ * configured RedemptionPolicy. Pure read — does not record anything.
1589
+ * Use this for UI to render "X PT redeemable now / next available at …".
1590
+ */
1591
+ async handleRedemptionPreview(userAddress, request) {
1592
+ if (!this.redemption) {
1593
+ throw new ConfigurationError(
1594
+ "REDEMPTION_NOT_CONFIGURED",
1595
+ "handleRedemptionPreview: redemption is not configured on this issuer"
1596
+ );
1597
+ }
1598
+ const tokenAddress = request.pointTokenAddress ? this.requireSupportedToken((0, import_viem6.getAddress)(request.pointTokenAddress), "handleRedemptionPreview") : void 0;
1599
+ const preview = await this.redemption.preview(
1600
+ (0, import_viem6.getAddress)(userAddress),
1601
+ tokenAddress
1602
+ );
1603
+ return preview;
1604
+ }
1605
+ /**
1606
+ * `POST /redemption/evaluate`
1607
+ *
1608
+ * Pre-flight check before the issuer signs a BurnRequest. Returns
1609
+ * { allowed, denial?, preview }. Caller (the burn-orchestrator) MUST
1610
+ * re-check on the actual initiate path — evaluate is read-only and a
1611
+ * caller could race two requests under the same headroom. The intended
1612
+ * write path is: evaluate → sign BurnRequest → reserve pending credit
1613
+ * → call `service.redemption.recordSuccessfulInitiate()`.
1614
+ */
1615
+ async handleRedemptionEvaluate(userAddress, request) {
1616
+ if (!this.redemption) {
1617
+ throw new ConfigurationError(
1618
+ "REDEMPTION_NOT_CONFIGURED",
1619
+ "handleRedemptionEvaluate: redemption is not configured on this issuer"
1620
+ );
1621
+ }
1622
+ if (request.amountPt <= 0n) {
1623
+ throw new import_core3.ValidationError(
1624
+ "INVALID_AMOUNT",
1625
+ "handleRedemptionEvaluate: amountPt must be positive",
1626
+ { amountPt: request.amountPt.toString() }
1627
+ );
1628
+ }
1629
+ const tokenAddress = request.pointTokenAddress ? this.requireSupportedToken((0, import_viem6.getAddress)(request.pointTokenAddress), "handleRedemptionEvaluate") : void 0;
1630
+ const decision = await this.redemption.evaluate(
1631
+ (0, import_viem6.getAddress)(userAddress),
1632
+ request.amountPt,
1633
+ tokenAddress
1634
+ );
1635
+ const response = {
1636
+ allowed: decision.allowed,
1637
+ preview: decision.preview
1638
+ };
1639
+ if (decision.denial) response.denial = decision.denial;
1640
+ return response;
1641
+ }
1642
+ requireSupportedToken(pointToken, handler) {
1643
+ if (!this.supportedTokens.has(pointToken)) {
1644
+ throw new import_core3.ValidationError(
1645
+ "UNSUPPORTED_POINT_TOKEN",
1646
+ `${handler}: unsupported pointToken ${pointToken}`,
1647
+ { requested: pointToken }
1648
+ );
1649
+ }
1650
+ return pointToken;
1651
+ }
1335
1652
  };
1336
1653
 
1337
1654
  // src/api/handlers/ptRedeemHandler.ts
@@ -1342,9 +1659,13 @@ var DEFAULT_SIG_DEADLINE_SEC = 15 * 60;
1342
1659
  var PTRedeemError = class extends import_core.PafiSdkError {
1343
1660
  httpStatus = "unprocessable";
1344
1661
  code;
1345
- constructor(code, message) {
1662
+ policyDenialCode;
1663
+ constructor(code, message, options) {
1346
1664
  super(message);
1347
1665
  this.code = code;
1666
+ if (options?.policyDenialCode) {
1667
+ this.policyDenialCode = options.policyDenialCode;
1668
+ }
1348
1669
  }
1349
1670
  };
1350
1671
  var PTRedeemHandler = class {
@@ -1360,6 +1681,7 @@ var PTRedeemHandler = class {
1360
1681
  redeemLockDurationMs;
1361
1682
  signatureDeadlineSeconds;
1362
1683
  now;
1684
+ redemptionService;
1363
1685
  /**
1364
1686
  * Per-user in-flight nonce guard (single-process only).
1365
1687
  *
@@ -1402,6 +1724,9 @@ var PTRedeemHandler = class {
1402
1724
  this.redeemLockDurationMs = config.redeemLockDurationMs ?? DEFAULT_REDEEM_LOCK_MS;
1403
1725
  this.signatureDeadlineSeconds = config.signatureDeadlineSeconds ?? DEFAULT_SIG_DEADLINE_SEC;
1404
1726
  this.now = config.now ?? (() => Date.now());
1727
+ if (config.redemptionService) {
1728
+ this.redemptionService = config.redemptionService;
1729
+ }
1405
1730
  }
1406
1731
  async handle(request) {
1407
1732
  if ((0, import_viem7.getAddress)(request.authenticatedAddress) !== (0, import_viem7.getAddress)(request.userAddress)) {
@@ -1413,6 +1738,21 @@ var PTRedeemHandler = class {
1413
1738
  if (request.amount <= 0n) {
1414
1739
  throw new PTRedeemError("INVALID_AMOUNT", "redeem amount must be positive");
1415
1740
  }
1741
+ if (this.redemptionService) {
1742
+ const decision = await this.redemptionService.evaluate(
1743
+ request.userAddress,
1744
+ request.amount,
1745
+ this.pointTokenAddress
1746
+ );
1747
+ if (!decision.allowed) {
1748
+ const denial = decision.denial;
1749
+ throw new PTRedeemError(
1750
+ "REDEMPTION_POLICY_DENIED",
1751
+ `redemption denied: ${denial.message}`,
1752
+ { policyDenialCode: denial.code }
1753
+ );
1754
+ }
1755
+ }
1416
1756
  let burnNonce;
1417
1757
  try {
1418
1758
  burnNonce = await this.provider.readContract({
@@ -1566,6 +1906,15 @@ var PTRedeemHandler = class {
1566
1906
  netCreditAmount: request.amount
1567
1907
  };
1568
1908
  }
1909
+ if (this.redemptionService) {
1910
+ await this.redemptionService.recordSuccessfulInitiate({
1911
+ user: request.userAddress,
1912
+ amountPt: request.amount,
1913
+ pointTokenAddress: this.pointTokenAddress,
1914
+ reservationId: sponsoredLockId
1915
+ }).catch(() => {
1916
+ });
1917
+ }
1569
1918
  return {
1570
1919
  lockId: sponsoredLockId,
1571
1920
  userOp: sponsoredUserOp,
@@ -3289,6 +3638,7 @@ var BalanceAggregator = class {
3289
3638
  };
3290
3639
 
3291
3640
  // src/pafi-backend/client.ts
3641
+ var import_core17 = require("@pafi-dev/core");
3292
3642
  function serializeBigInt(_key, value) {
3293
3643
  return typeof value === "bigint" ? value.toString(10) : value;
3294
3644
  }
@@ -3297,10 +3647,13 @@ function sleep(ms) {
3297
3647
  }
3298
3648
  var PafiBackendClient = class {
3299
3649
  config;
3650
+ baseUrl;
3300
3651
  constructor(config) {
3301
- if (!config.url) throw new Error("PafiBackendClient: url is required");
3652
+ if (!config.chainId) throw new Error("PafiBackendClient: chainId is required");
3302
3653
  if (!config.issuerId) throw new Error("PafiBackendClient: issuerId is required");
3654
+ if (!config.apiKey) throw new Error("PafiBackendClient: apiKey is required");
3303
3655
  this.config = config;
3656
+ this.baseUrl = (0, import_core17.getPafiServiceUrls)(config.chainId).sponsorRelayer;
3304
3657
  }
3305
3658
  async requestSponsorship(request) {
3306
3659
  const maxAttempts = this.config.retry?.maxAttempts ?? 1;
@@ -3335,7 +3688,7 @@ var PafiBackendClient = class {
3335
3688
  */
3336
3689
  async getUserOpReceipt(userOpHash) {
3337
3690
  const fetchFn = this.config.fetchImpl ?? fetch;
3338
- const url = `${this.config.url}/bundler/receipt`;
3691
+ const url = `${this.baseUrl}/bundler/receipt`;
3339
3692
  let response;
3340
3693
  try {
3341
3694
  response = await fetchFn(url, {
@@ -3374,7 +3727,7 @@ var PafiBackendClient = class {
3374
3727
  }
3375
3728
  async relayUserOperation(request) {
3376
3729
  const fetchFn = this.config.fetchImpl ?? fetch;
3377
- const url = `${this.config.url}/bundler/relay`;
3730
+ const url = `${this.baseUrl}/bundler/relay`;
3378
3731
  let response;
3379
3732
  try {
3380
3733
  response = await fetchFn(url, {
@@ -3408,7 +3761,7 @@ var PafiBackendClient = class {
3408
3761
  }
3409
3762
  async _doRequest(request) {
3410
3763
  const fetchFn = this.config.fetchImpl ?? fetch;
3411
- const url = `${this.config.url}/paymaster/sponsor`;
3764
+ const url = `${this.baseUrl}/paymaster/sponsor`;
3412
3765
  const body = JSON.stringify(request, serializeBigInt);
3413
3766
  let response;
3414
3767
  try {
@@ -3463,7 +3816,313 @@ var PafiBackendClient = class {
3463
3816
 
3464
3817
  // src/config.ts
3465
3818
  var import_viem14 = require("viem");
3466
- var import_core17 = require("@pafi-dev/core");
3819
+ var import_core19 = require("@pafi-dev/core");
3820
+
3821
+ // src/redemption/evaluator.ts
3822
+ var SECONDS_PER_DAY = 24 * 60 * 60;
3823
+ function evaluateRedemption(input) {
3824
+ const { policy, history, amountPt, nowUnixSec, policySource } = input;
3825
+ const dailyRemaining = policy.dailyLimitPt > history.redeemedLast24hPt ? policy.dailyLimitPt - history.redeemedLast24hPt : 0n;
3826
+ const cooldownUntilUnixSec = history.lastRedeemedAtUnixSec !== null ? history.lastRedeemedAtUnixSec + policy.cooldownSec : null;
3827
+ const inCooldown = cooldownUntilUnixSec !== null && cooldownUntilUnixSec > nowUnixSec;
3828
+ const activeBlackout = findActiveBlackout(policy.blackoutWindows, nowUnixSec);
3829
+ const nextBlackoutEnd = nextBlackoutEndAfter(
3830
+ policy.blackoutWindows,
3831
+ nowUnixSec
3832
+ );
3833
+ let availableAmountPt = 0n;
3834
+ if (!inCooldown && !activeBlackout) {
3835
+ const headroom = dailyRemaining < policy.perTxMaxPt ? dailyRemaining : policy.perTxMaxPt;
3836
+ availableAmountPt = headroom >= policy.perTxMinPt ? headroom : 0n;
3837
+ }
3838
+ const preview = {
3839
+ availableAmountPt,
3840
+ dailyRemainingPt: dailyRemaining,
3841
+ cooldownUntilUnixSec: inCooldown ? cooldownUntilUnixSec : null,
3842
+ nextBlackoutEndsAtUnixSec: activeBlackout ? activeBlackout.endUnixSec : nextBlackoutEnd,
3843
+ perTxMinPt: policy.perTxMinPt,
3844
+ perTxMaxPt: policy.perTxMaxPt,
3845
+ policyVersion: policy.version,
3846
+ policySource
3847
+ };
3848
+ if (amountPt <= 0n) {
3849
+ return { allowed: false, preview, denial: rejectAmountBelowMin(policy) };
3850
+ }
3851
+ const denial = firstDenial({
3852
+ amountPt,
3853
+ policy,
3854
+ dailyRemaining,
3855
+ inCooldown,
3856
+ cooldownUntilUnixSec,
3857
+ activeBlackout
3858
+ });
3859
+ if (denial) return { allowed: false, denial, preview };
3860
+ return { allowed: true, preview };
3861
+ }
3862
+ function firstDenial(args) {
3863
+ const { amountPt, policy, dailyRemaining, inCooldown, cooldownUntilUnixSec, activeBlackout } = args;
3864
+ if (activeBlackout) {
3865
+ return {
3866
+ code: "BLACKOUT_WINDOW",
3867
+ message: `Redemption is blocked until ${new Date(
3868
+ activeBlackout.endUnixSec * 1e3
3869
+ ).toISOString()}${activeBlackout.reason ? ` (${activeBlackout.reason})` : ""}`
3870
+ };
3871
+ }
3872
+ if (inCooldown && cooldownUntilUnixSec !== null) {
3873
+ return {
3874
+ code: "COOLDOWN_ACTIVE",
3875
+ message: `Cooldown active until ${new Date(
3876
+ cooldownUntilUnixSec * 1e3
3877
+ ).toISOString()}`
3878
+ };
3879
+ }
3880
+ if (amountPt < policy.perTxMinPt) {
3881
+ return {
3882
+ code: "AMOUNT_BELOW_MIN",
3883
+ message: `amount ${amountPt} below per-tx minimum ${policy.perTxMinPt}`
3884
+ };
3885
+ }
3886
+ if (amountPt > policy.perTxMaxPt) {
3887
+ return {
3888
+ code: "AMOUNT_ABOVE_MAX",
3889
+ message: `amount ${amountPt} above per-tx maximum ${policy.perTxMaxPt}`
3890
+ };
3891
+ }
3892
+ if (amountPt > dailyRemaining) {
3893
+ return {
3894
+ code: "DAILY_LIMIT_EXCEEDED",
3895
+ message: `amount ${amountPt} exceeds daily remaining ${dailyRemaining}`
3896
+ };
3897
+ }
3898
+ return null;
3899
+ }
3900
+ function rejectAmountBelowMin(policy) {
3901
+ return {
3902
+ code: "AMOUNT_BELOW_MIN",
3903
+ message: `amount must be >= ${policy.perTxMinPt}`
3904
+ };
3905
+ }
3906
+ function findActiveBlackout(windows, nowUnixSec) {
3907
+ for (const w of windows) {
3908
+ if (w.startUnixSec <= nowUnixSec && nowUnixSec < w.endUnixSec) return w;
3909
+ }
3910
+ return null;
3911
+ }
3912
+ function nextBlackoutEndAfter(windows, nowUnixSec) {
3913
+ let earliest = null;
3914
+ for (const w of windows) {
3915
+ if (w.endUnixSec > nowUnixSec) {
3916
+ if (earliest === null || w.endUnixSec < earliest) earliest = w.endUnixSec;
3917
+ }
3918
+ }
3919
+ return earliest;
3920
+ }
3921
+ var REDEMPTION_HISTORY_WINDOW_SEC = SECONDS_PER_DAY;
3922
+
3923
+ // src/redemption/settlementClient.ts
3924
+ var import_core18 = require("@pafi-dev/core");
3925
+ var DEFAULT_TIMEOUT_MS = 1e3;
3926
+ var SettlementClient = class {
3927
+ config;
3928
+ constructor(config) {
3929
+ if (!config.chainId) throw new Error("SettlementClient: chainId is required");
3930
+ if (!config.issuerId) throw new Error("SettlementClient: issuerId is required");
3931
+ if (!config.apiKey) throw new Error("SettlementClient: apiKey is required");
3932
+ this.config = {
3933
+ baseUrl: (0, import_core18.getPafiServiceUrls)(config.chainId).issuerApi.replace(/\/+$/, ""),
3934
+ issuerId: config.issuerId,
3935
+ apiKey: config.apiKey,
3936
+ fetchTimeoutMs: config.fetchTimeoutMs ?? DEFAULT_TIMEOUT_MS,
3937
+ fetchImpl: config.fetchImpl
3938
+ };
3939
+ }
3940
+ async fetchPolicy() {
3941
+ const fetchFn = this.config.fetchImpl ?? fetch;
3942
+ const url = `${this.config.baseUrl}/issuers/${encodeURIComponent(this.config.issuerId)}/redemption-policy`;
3943
+ const controller = new AbortController();
3944
+ const timer = setTimeout(
3945
+ () => controller.abort(),
3946
+ this.config.fetchTimeoutMs
3947
+ );
3948
+ let response;
3949
+ try {
3950
+ response = await fetchFn(url, {
3951
+ method: "GET",
3952
+ headers: {
3953
+ "Content-Type": "application/json",
3954
+ Authorization: `Bearer ${this.config.apiKey}`,
3955
+ "X-Issuer-Id": this.config.issuerId
3956
+ },
3957
+ signal: controller.signal
3958
+ });
3959
+ } catch (err) {
3960
+ const isAbort = err instanceof Error && (err.name === "AbortError" || /aborted|timeout/i.test(err.message ?? ""));
3961
+ return { ok: false, reason: isAbort ? "TIMEOUT" : "NETWORK" };
3962
+ } finally {
3963
+ clearTimeout(timer);
3964
+ }
3965
+ if (response.status === 404) {
3966
+ return { ok: false, reason: "NOT_FOUND", status: 404 };
3967
+ }
3968
+ if (response.status === 401 || response.status === 403) {
3969
+ return { ok: false, reason: "UNAUTHORIZED", status: response.status };
3970
+ }
3971
+ if (!response.ok) {
3972
+ return { ok: false, reason: "SERVER_ERROR", status: response.status };
3973
+ }
3974
+ let raw;
3975
+ try {
3976
+ raw = await response.json();
3977
+ } catch {
3978
+ return { ok: false, reason: "INVALID_RESPONSE", status: response.status };
3979
+ }
3980
+ const parsed = parsePolicyDto(raw);
3981
+ if (!parsed) {
3982
+ return { ok: false, reason: "INVALID_RESPONSE", status: response.status };
3983
+ }
3984
+ return { ok: true, policy: parsed };
3985
+ }
3986
+ };
3987
+ function parsePolicyDto(raw) {
3988
+ if (!raw || typeof raw !== "object") return null;
3989
+ const dto = raw;
3990
+ if (typeof dto.issuerId !== "string" || typeof dto.dailyLimitPt !== "string" || typeof dto.cooldownSec !== "number" || typeof dto.perTxMinPt !== "string" || typeof dto.perTxMaxPt !== "string" || typeof dto.version !== "string" || !Array.isArray(dto.blackoutWindows)) {
3991
+ return null;
3992
+ }
3993
+ try {
3994
+ return {
3995
+ issuerId: dto.issuerId,
3996
+ dailyLimitPt: BigInt(dto.dailyLimitPt),
3997
+ cooldownSec: dto.cooldownSec,
3998
+ perTxMinPt: BigInt(dto.perTxMinPt),
3999
+ perTxMaxPt: BigInt(dto.perTxMaxPt),
4000
+ blackoutWindows: dto.blackoutWindows.map(normalizeBlackout).filter((b) => b !== null),
4001
+ version: dto.version
4002
+ };
4003
+ } catch {
4004
+ return null;
4005
+ }
4006
+ }
4007
+ function normalizeBlackout(raw) {
4008
+ if (!raw || typeof raw !== "object") return null;
4009
+ const win = raw;
4010
+ if (typeof win.startUnixSec !== "number" || typeof win.endUnixSec !== "number" || win.startUnixSec >= win.endUnixSec) {
4011
+ return null;
4012
+ }
4013
+ return {
4014
+ startUnixSec: win.startUnixSec,
4015
+ endUnixSec: win.endUnixSec,
4016
+ reason: typeof win.reason === "string" ? win.reason : void 0
4017
+ };
4018
+ }
4019
+
4020
+ // src/redemption/defaults.ts
4021
+ var PT_DECIMALS = 10n ** 18n;
4022
+ var DEFAULT_REDEMPTION_POLICY = {
4023
+ issuerId: "default",
4024
+ dailyLimitPt: 1000n * PT_DECIMALS,
4025
+ cooldownSec: 60,
4026
+ perTxMinPt: 1n * PT_DECIMALS,
4027
+ perTxMaxPt: 500n * PT_DECIMALS,
4028
+ blackoutWindows: [],
4029
+ version: "default-v1"
4030
+ };
4031
+ function defaultPolicyFor(issuerId) {
4032
+ return { ...DEFAULT_REDEMPTION_POLICY, issuerId };
4033
+ }
4034
+
4035
+ // src/redemption/policyProvider.ts
4036
+ var DEFAULT_CACHE_TTL_MS3 = 5 * 60 * 1e3;
4037
+ var PolicyProvider = class {
4038
+ client;
4039
+ issuerId;
4040
+ cacheTtlMs;
4041
+ now;
4042
+ cache = null;
4043
+ inflight = null;
4044
+ constructor(config) {
4045
+ this.client = new SettlementClient(config);
4046
+ this.issuerId = config.issuerId;
4047
+ this.cacheTtlMs = config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS3;
4048
+ this.now = config.now ?? (() => Date.now());
4049
+ }
4050
+ async getPolicy() {
4051
+ const fresh = this.readCache();
4052
+ if (fresh) return { policy: fresh, source: "cache" };
4053
+ if (this.inflight) return this.inflight;
4054
+ this.inflight = this.fetchAndStore().finally(() => {
4055
+ this.inflight = null;
4056
+ });
4057
+ return this.inflight;
4058
+ }
4059
+ /** Drop cached policy. Next getPolicy() will refetch. */
4060
+ invalidate() {
4061
+ this.cache = null;
4062
+ }
4063
+ readCache() {
4064
+ if (!this.cache) return null;
4065
+ if (this.cache.expiresAtMs <= this.now()) {
4066
+ this.cache = null;
4067
+ return null;
4068
+ }
4069
+ return this.cache.policy;
4070
+ }
4071
+ async fetchAndStore() {
4072
+ const result = await this.client.fetchPolicy();
4073
+ if (result.ok) {
4074
+ this.cache = {
4075
+ policy: result.policy,
4076
+ expiresAtMs: this.now() + this.cacheTtlMs
4077
+ };
4078
+ return { policy: result.policy, source: "settlement" };
4079
+ }
4080
+ return { policy: defaultPolicyFor(this.issuerId), source: "default" };
4081
+ }
4082
+ };
4083
+
4084
+ // src/redemption/service.ts
4085
+ var RedemptionService = class {
4086
+ policyProvider;
4087
+ historyStore;
4088
+ nowUnixSec;
4089
+ constructor(config) {
4090
+ this.policyProvider = config.policyProvider instanceof PolicyProvider ? config.policyProvider : new PolicyProvider(config.policyProvider);
4091
+ this.historyStore = config.historyStore;
4092
+ this.nowUnixSec = config.nowUnixSec ?? (() => Math.floor(Date.now() / 1e3));
4093
+ }
4094
+ async preview(user, pointTokenAddress) {
4095
+ const decision = await this.evaluate(user, 0n, pointTokenAddress);
4096
+ return decision.preview;
4097
+ }
4098
+ async evaluate(user, amountPt, pointTokenAddress) {
4099
+ const { policy, source } = await this.policyProvider.getPolicy();
4100
+ const now = this.nowUnixSec();
4101
+ const [redeemedLast24hPt, lastRedeemedAtUnixSec] = await Promise.all([
4102
+ this.historyStore.sumRedeemedSince(
4103
+ user,
4104
+ now - REDEMPTION_HISTORY_WINDOW_SEC,
4105
+ pointTokenAddress
4106
+ ),
4107
+ this.historyStore.getLastRedeemedAtUnixSec(user, pointTokenAddress)
4108
+ ]);
4109
+ return evaluateRedemption({
4110
+ policy,
4111
+ policySource: source,
4112
+ history: { redeemedLast24hPt, lastRedeemedAtUnixSec },
4113
+ amountPt,
4114
+ nowUnixSec: now
4115
+ });
4116
+ }
4117
+ async recordSuccessfulInitiate(entry) {
4118
+ await this.historyStore.recordRedemption({
4119
+ ...entry,
4120
+ unixSec: this.nowUnixSec()
4121
+ });
4122
+ }
4123
+ };
4124
+
4125
+ // src/config.ts
3467
4126
  function createIssuerService(config) {
3468
4127
  if (!config.provider) {
3469
4128
  throw new Error("createIssuerService: provider is required");
@@ -3533,7 +4192,7 @@ function createIssuerService(config) {
3533
4192
  indexers.set(tokenAddress, new PointIndexer(indexerConfig));
3534
4193
  }
3535
4194
  const firstIndexer = indexers.get(tokenAddresses[0]);
3536
- const chainAddresses = (0, import_core17.getContractAddresses)(config.chainId);
4195
+ const chainAddresses = (0, import_core19.getContractAddresses)(config.chainId);
3537
4196
  const resolvedContracts = {
3538
4197
  batchExecutor: chainAddresses.batchExecutor,
3539
4198
  usdt: chainAddresses.usdt,
@@ -3542,6 +4201,25 @@ function createIssuerService(config) {
3542
4201
  pafiHook: chainAddresses.pafiHook,
3543
4202
  ...config.contracts
3544
4203
  };
4204
+ let redemption;
4205
+ if (config.redemption) {
4206
+ const policyConfig = {
4207
+ chainId: config.chainId,
4208
+ issuerId: config.redemption.issuerId,
4209
+ apiKey: config.redemption.apiKey
4210
+ };
4211
+ if (config.redemption.fetchImpl) policyConfig.fetchImpl = config.redemption.fetchImpl;
4212
+ if (config.redemption.fetchTimeoutMs !== void 0) {
4213
+ policyConfig.fetchTimeoutMs = config.redemption.fetchTimeoutMs;
4214
+ }
4215
+ if (config.redemption.cacheTtlMs !== void 0) {
4216
+ policyConfig.cacheTtlMs = config.redemption.cacheTtlMs;
4217
+ }
4218
+ redemption = new RedemptionService({
4219
+ policyProvider: new PolicyProvider(policyConfig),
4220
+ historyStore: config.redemption.historyStore
4221
+ });
4222
+ }
3545
4223
  const handlersConfig = {
3546
4224
  authService,
3547
4225
  ledger,
@@ -3552,6 +4230,7 @@ function createIssuerService(config) {
3552
4230
  };
3553
4231
  if (feeManager) handlersConfig.feeManager = feeManager;
3554
4232
  if (config.poolsProvider) handlersConfig.poolsProvider = config.poolsProvider;
4233
+ if (redemption) handlersConfig.redemption = redemption;
3555
4234
  const handlers = new IssuerApiHandlers(handlersConfig);
3556
4235
  if (config.indexer?.autoStart) {
3557
4236
  for (const idx of indexers.values()) {
@@ -3567,13 +4246,14 @@ function createIssuerService(config) {
3567
4246
  fee: feeManager,
3568
4247
  indexers,
3569
4248
  indexer: firstIndexer,
3570
- api: handlers
4249
+ api: handlers,
4250
+ redemption
3571
4251
  };
3572
4252
  }
3573
4253
 
3574
4254
  // src/issuer-state/validator.ts
3575
4255
  var import_viem15 = require("viem");
3576
- var import_core18 = require("@pafi-dev/core");
4256
+ var import_core20 = require("@pafi-dev/core");
3577
4257
  var ISSUER_RECORD_TTL_MS = 3e4;
3578
4258
  var IssuerStateValidator = class _IssuerStateValidator {
3579
4259
  constructor(provider, registryAddress) {
@@ -3590,7 +4270,7 @@ var IssuerStateValidator = class _IssuerStateValidator {
3590
4270
  * `CONTRACT_ADDRESSES` map for the given chain.
3591
4271
  */
3592
4272
  static forChain(provider, chainId) {
3593
- const { issuerRegistry } = (0, import_core18.getContractAddresses)(chainId);
4273
+ const { issuerRegistry } = (0, import_core20.getContractAddresses)(chainId);
3594
4274
  return new _IssuerStateValidator(provider, issuerRegistry);
3595
4275
  }
3596
4276
  /**
@@ -3619,7 +4299,7 @@ var IssuerStateValidator = class _IssuerStateValidator {
3619
4299
  if (cached) return cached;
3620
4300
  const issuer = await this.provider.readContract({
3621
4301
  address: key,
3622
- abi: import_core18.POINT_TOKEN_V2_ABI,
4302
+ abi: import_core20.POINT_TOKEN_V2_ABI,
3623
4303
  functionName: "issuer"
3624
4304
  });
3625
4305
  this.pointTokenIssuerCache.set(key, (0, import_viem15.getAddress)(issuer));
@@ -3700,13 +4380,13 @@ var IssuerStateValidator = class _IssuerStateValidator {
3700
4380
  const [issuerTuple, totalSupply] = await Promise.all([
3701
4381
  this.provider.readContract({
3702
4382
  address: this.registryAddress,
3703
- abi: import_core18.issuerRegistryGetIssuerFlatAbi,
4383
+ abi: import_core20.issuerRegistryGetIssuerFlatAbi,
3704
4384
  functionName: "getIssuer",
3705
4385
  args: [issuerAddr]
3706
4386
  }),
3707
4387
  this.provider.readContract({
3708
4388
  address: tokenAddr,
3709
- abi: import_core18.POINT_TOKEN_V2_ABI,
4389
+ abi: import_core20.POINT_TOKEN_V2_ABI,
3710
4390
  functionName: "totalSupply"
3711
4391
  })
3712
4392
  ]);
@@ -3727,8 +4407,44 @@ var IssuerStateValidator = class _IssuerStateValidator {
3727
4407
  }
3728
4408
  };
3729
4409
 
4410
+ // src/redemption/memoryHistoryStore.ts
4411
+ var MemoryRedemptionHistoryStore = class {
4412
+ entries = [];
4413
+ async sumRedeemedSince(user, sinceUnixSec, pointTokenAddress) {
4414
+ const userKey = user.toLowerCase();
4415
+ const tokenKey = pointTokenAddress?.toLowerCase() ?? null;
4416
+ let total = 0n;
4417
+ for (const e of this.entries) {
4418
+ if (e.user !== userKey) continue;
4419
+ if (e.unixSec < sinceUnixSec) continue;
4420
+ if (tokenKey !== null && e.pointTokenAddress !== tokenKey) continue;
4421
+ total += e.amountPt;
4422
+ }
4423
+ return total;
4424
+ }
4425
+ async getLastRedeemedAtUnixSec(user, pointTokenAddress) {
4426
+ const userKey = user.toLowerCase();
4427
+ const tokenKey = pointTokenAddress?.toLowerCase() ?? null;
4428
+ let latest = null;
4429
+ for (const e of this.entries) {
4430
+ if (e.user !== userKey) continue;
4431
+ if (tokenKey !== null && e.pointTokenAddress !== tokenKey) continue;
4432
+ if (latest === null || e.unixSec > latest) latest = e.unixSec;
4433
+ }
4434
+ return latest;
4435
+ }
4436
+ async recordRedemption(entry) {
4437
+ this.entries.push({
4438
+ user: entry.user.toLowerCase(),
4439
+ amountPt: entry.amountPt,
4440
+ pointTokenAddress: entry.pointTokenAddress?.toLowerCase() ?? null,
4441
+ unixSec: entry.unixSec
4442
+ });
4443
+ }
4444
+ };
4445
+
3730
4446
  // src/index.ts
3731
- var PAFI_ISSUER_SDK_VERSION = true ? "0.7.8" : "dev";
4447
+ var PAFI_ISSUER_SDK_VERSION = true ? "0.8.0" : "dev";
3732
4448
  // Annotate the CommonJS export names for ESM import in node:
3733
4449
  0 && (module.exports = {
3734
4450
  AdapterMisconfiguredError,
@@ -3739,6 +4455,7 @@ var PAFI_ISSUER_SDK_VERSION = true ? "0.7.8" : "dev";
3739
4455
  BundlerRejectedError,
3740
4456
  BurnIndexer,
3741
4457
  ConfigurationError,
4458
+ DEFAULT_REDEMPTION_POLICY,
3742
4459
  DefaultPolicyEngine,
3743
4460
  FeeManager,
3744
4461
  InMemoryCursorStore,
@@ -3748,8 +4465,11 @@ var PAFI_ISSUER_SDK_VERSION = true ? "0.7.8" : "dev";
3748
4465
  IssuerStateValidator,
3749
4466
  LockNotFoundError,
3750
4467
  MemoryPendingUserOpStore,
4468
+ MemoryRateLimiter,
4469
+ MemoryRedemptionHistoryStore,
3751
4470
  MemorySessionStore,
3752
4471
  NonceManager,
4472
+ NoopRateLimiter,
3753
4473
  PAFI_ISSUER_SDK_VERSION,
3754
4474
  PAFI_SUBGRAPH_URL,
3755
4475
  PTClaimError,
@@ -3764,8 +4484,12 @@ var PAFI_ISSUER_SDK_VERSION = true ? "0.7.8" : "dev";
3764
4484
  PerpDepositError,
3765
4485
  PerpDepositHandler,
3766
4486
  PointIndexer,
4487
+ PolicyProvider,
4488
+ REDEMPTION_HISTORY_WINDOW_SEC,
4489
+ RedemptionService,
3767
4490
  RelayError,
3768
4491
  RelayService,
4492
+ SettlementClient,
3769
4493
  ValidationError,
3770
4494
  authenticateRequest,
3771
4495
  createIssuerService,
@@ -3773,6 +4497,8 @@ var PAFI_ISSUER_SDK_VERSION = true ? "0.7.8" : "dev";
3773
4497
  createSdkErrorMapper,
3774
4498
  createSubgraphNativeUsdtQuoter,
3775
4499
  createSubgraphPoolsProvider,
4500
+ defaultPolicyFor,
4501
+ evaluateRedemption,
3776
4502
  handleClaimStatus,
3777
4503
  handleDelegateSubmit,
3778
4504
  handleMobilePrepare,