@pafi-dev/issuer 0.7.9 → 0.9.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,21 +57,34 @@ __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
+ SDK_ERROR_HTTP_STATUS_CODE: () => import_core.SDK_ERROR_HTTP_STATUS_CODE,
66
+ SettlementClient: () => SettlementClient,
58
67
  ValidationError: () => import_core3.ValidationError,
59
68
  authenticateRequest: () => authenticateRequest,
69
+ buildErrorEnvelope: () => buildErrorEnvelope,
70
+ buildSdkErrorBody: () => buildSdkErrorBody,
60
71
  createIssuerService: () => createIssuerService,
61
72
  createNativePtQuoter: () => createNativePtQuoter,
62
73
  createSdkErrorMapper: () => createSdkErrorMapper,
63
74
  createSubgraphNativeUsdtQuoter: () => createSubgraphNativeUsdtQuoter,
64
75
  createSubgraphPoolsProvider: () => createSubgraphPoolsProvider,
76
+ defaultErrorTypeForStatus: () => import_core.defaultErrorTypeForStatus,
77
+ defaultPolicyFor: () => defaultPolicyFor,
78
+ evaluateRedemption: () => evaluateRedemption,
65
79
  handleClaimStatus: () => handleClaimStatus,
66
80
  handleDelegateSubmit: () => handleDelegateSubmit,
67
81
  handleMobilePrepare: () => handleMobilePrepare,
68
82
  handleMobileSubmit: () => handleMobileSubmit,
69
83
  handleRedeemStatus: () => handleRedeemStatus,
70
84
  mergePaymasterFields: () => mergePaymasterFields,
85
+ payloadFromGenericError: () => payloadFromGenericError,
86
+ payloadFromHttpException: () => payloadFromHttpException,
87
+ payloadFromPafiSdkError: () => payloadFromPafiSdkError,
71
88
  prepareMobileUserOp: () => prepareMobileUserOp,
72
89
  relayUserOp: () => relayUserOp,
73
90
  requestPaymaster: () => requestPaymaster,
@@ -91,6 +108,152 @@ var ConfigurationError = class extends import_core2.PafiSdkError {
91
108
  }
92
109
  };
93
110
 
111
+ // src/api/errorMapper.ts
112
+ function buildSdkErrorBody(err) {
113
+ const type = err.type ?? (0, import_core.defaultErrorTypeForStatus)(import_core.SDK_ERROR_HTTP_STATUS_CODE[err.httpStatus]);
114
+ const body = {
115
+ type,
116
+ code: err.code,
117
+ message: err.message,
118
+ safeToRetry: err.safeToRetry
119
+ };
120
+ if (err.param) body.param = err.param;
121
+ if (err.metadata) body.metadata = err.metadata;
122
+ if (err.details !== void 0) body.details = err.details;
123
+ return body;
124
+ }
125
+ function createSdkErrorMapper(factories) {
126
+ return (err) => {
127
+ if (!(err instanceof import_core.PafiSdkError)) {
128
+ throw err;
129
+ }
130
+ const body = buildSdkErrorBody(err);
131
+ switch (err.httpStatus) {
132
+ case "not_found":
133
+ throw factories.notFound(body);
134
+ case "forbidden":
135
+ throw factories.forbidden(body);
136
+ case "unprocessable":
137
+ throw factories.unprocessable(body);
138
+ case "service_unavailable":
139
+ throw factories.serviceUnavailable(body);
140
+ }
141
+ };
142
+ }
143
+
144
+ // src/http/errorEnvelope.ts
145
+ function isValidationPipeBody(body) {
146
+ return Array.isArray(body["message"]) && typeof body["error"] === "string" && body["error"] === "Bad Request";
147
+ }
148
+ function extractParamFromValidatorMessage(message) {
149
+ const idx = message.indexOf(" ");
150
+ if (idx <= 0) return void 0;
151
+ const candidate = message.slice(0, idx);
152
+ return /^[A-Za-z_][\w.]*$/.test(candidate) ? candidate : void 0;
153
+ }
154
+ function normalizeValidationPipeBody(body) {
155
+ const fields = {};
156
+ for (const msg of body.message) {
157
+ const param2 = extractParamFromValidatorMessage(msg);
158
+ const key = param2 ?? "_";
159
+ (fields[key] ??= []).push(msg);
160
+ }
161
+ const fieldKeys = Object.keys(fields).filter((k) => k !== "_");
162
+ const param = fieldKeys.length === 1 ? fieldKeys[0] : void 0;
163
+ const payload = {
164
+ type: "validation_error",
165
+ code: "VALIDATION_FAILED",
166
+ message: body.message.join("; "),
167
+ safeToRetry: false,
168
+ metadata: { fieldErrors: fields }
169
+ };
170
+ if (param) payload.param = param;
171
+ return payload;
172
+ }
173
+ function normalizeHttpExceptionBody(desc) {
174
+ const { statusCode, responseBody, exceptionName, fallbackMessage } = desc;
175
+ const defaultType = (0, import_core.defaultErrorTypeForStatus)(statusCode);
176
+ if (typeof responseBody === "string") {
177
+ return {
178
+ type: defaultType,
179
+ code: exceptionName,
180
+ message: responseBody,
181
+ safeToRetry: false
182
+ };
183
+ }
184
+ if (responseBody && typeof responseBody === "object") {
185
+ const body = responseBody;
186
+ if (isValidationPipeBody(body)) {
187
+ return normalizeValidationPipeBody(body);
188
+ }
189
+ const code = typeof body["code"] === "string" && body["code"] || typeof body["error"] === "string" && body["error"] || exceptionName;
190
+ const message = typeof body["message"] === "string" && body["message"] || (Array.isArray(body["message"]) ? body["message"].join("; ") : "") || fallbackMessage;
191
+ const type = typeof body["type"] === "string" && body["type"] || defaultType;
192
+ const safeToRetry = typeof body["safeToRetry"] === "boolean" ? body["safeToRetry"] : false;
193
+ const payload = {
194
+ type,
195
+ code,
196
+ message,
197
+ safeToRetry
198
+ };
199
+ if (typeof body["param"] === "string") payload.param = body["param"];
200
+ if (body["metadata"] && typeof body["metadata"] === "object") {
201
+ payload.metadata = body["metadata"];
202
+ }
203
+ if (body["details"] !== void 0) payload.details = body["details"];
204
+ return payload;
205
+ }
206
+ return {
207
+ type: defaultType,
208
+ code: exceptionName || "INTERNAL_SERVER_ERROR",
209
+ message: fallbackMessage || "An unexpected error occurred",
210
+ safeToRetry: false
211
+ };
212
+ }
213
+ function payloadFromPafiSdkError(err) {
214
+ const body = buildSdkErrorBody(err);
215
+ const payload = {
216
+ type: body.type,
217
+ code: body.code,
218
+ message: body.message,
219
+ safeToRetry: body.safeToRetry
220
+ };
221
+ if (body.param) payload.param = body.param;
222
+ if (body.metadata) payload.metadata = body.metadata;
223
+ if (body.details !== void 0) payload.details = body.details;
224
+ return payload;
225
+ }
226
+ function sanitizeDbErrorMessage(message) {
227
+ if (/^[A-Z_]+: /.test(message) && message.length < 256) return message;
228
+ return "Internal database error";
229
+ }
230
+ function buildErrorEnvelope(input) {
231
+ const now = (input.ctx.now ?? (() => /* @__PURE__ */ new Date()))();
232
+ return {
233
+ success: false,
234
+ statusCode: input.status,
235
+ error: input.payload,
236
+ meta: {
237
+ timestamp: now.toISOString(),
238
+ requestId: input.ctx.requestId,
239
+ path: input.ctx.path
240
+ }
241
+ };
242
+ }
243
+ function payloadFromHttpException(desc) {
244
+ return normalizeHttpExceptionBody(desc);
245
+ }
246
+ function payloadFromGenericError(err) {
247
+ const name = err.name || "INTERNAL_SERVER_ERROR";
248
+ const isDbError = name === "QueryFailedError" || name === "EntityNotFoundError";
249
+ return {
250
+ type: "server_error",
251
+ code: name,
252
+ message: isDbError ? sanitizeDbErrorMessage(err.message) : err.message || "An unexpected error occurred",
253
+ safeToRetry: false
254
+ };
255
+ }
256
+
94
257
  // src/policy/defaultPolicy.ts
95
258
  var DefaultPolicyEngine = class {
96
259
  ledger;
@@ -161,9 +324,9 @@ var MemorySessionStore = class {
161
324
  nonceTtlMs;
162
325
  now;
163
326
  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."
327
+ if (process.env.NODE_ENV === "production" && !opts.dangerouslyAllowMemoryStoreInProduction) {
328
+ throw new Error(
329
+ "[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
330
  );
168
331
  }
169
332
  this.nonceTtlMs = opts.nonceTtlMs ?? DEFAULT_NONCE_TTL_MS;
@@ -290,6 +453,64 @@ var AuthError = class extends import_core.PafiSdkError {
290
453
  };
291
454
 
292
455
  // src/auth/loginVerifier.ts
456
+ function assertJwtSecretStrength(secret) {
457
+ if (!secret) {
458
+ throw new Error(
459
+ "AuthService: jwtSecret is required. Generate via `node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"`"
460
+ );
461
+ }
462
+ if (secret.length < 32) {
463
+ throw new Error(
464
+ `AuthService: jwtSecret too short (${secret.length} chars; need \u2265 32). HS256 brute-force becomes feasible below this threshold.`
465
+ );
466
+ }
467
+ const uniqueChars = new Set(secret).size;
468
+ if (uniqueChars < 16) {
469
+ throw new Error(
470
+ `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')\`.`
471
+ );
472
+ }
473
+ const entropy = shannonEntropyBitsPerChar(secret);
474
+ if (entropy < 3.5) {
475
+ throw new Error(
476
+ `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).`
477
+ );
478
+ }
479
+ for (let period = 1; period <= secret.length / 4; period++) {
480
+ if (secret.length % period !== 0) continue;
481
+ const head = secret.slice(0, period);
482
+ if (secret === head.repeat(secret.length / period)) {
483
+ throw new Error(
484
+ `AuthService: jwtSecret is a repeating pattern of period ${period}. Use \`crypto.randomBytes(32).toString('hex')\`.`
485
+ );
486
+ }
487
+ }
488
+ }
489
+ function shannonEntropyBitsPerChar(s) {
490
+ const counts = /* @__PURE__ */ new Map();
491
+ for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1);
492
+ const len = s.length;
493
+ let h = 0;
494
+ for (const n of counts.values()) {
495
+ const p = n / len;
496
+ h -= p * Math.log2(p);
497
+ }
498
+ return h;
499
+ }
500
+ function decodeExpiredJwtJti(token) {
501
+ try {
502
+ const parts = token.split(".");
503
+ if (parts.length !== 3) return {};
504
+ const payloadB64 = parts[1];
505
+ if (!payloadB64) return {};
506
+ const padded = payloadB64.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((payloadB64.length + 3) % 4);
507
+ const json = Buffer.from(padded, "base64").toString("utf-8");
508
+ const claims = JSON.parse(json);
509
+ return typeof claims.jti === "string" ? { jti: claims.jti } : {};
510
+ } catch {
511
+ return {};
512
+ }
513
+ }
293
514
  var DEFAULT_EXPIRES_IN = "24h";
294
515
  var AuthService = class {
295
516
  sessionStore;
@@ -297,17 +518,19 @@ var AuthService = class {
297
518
  jwtExpiresIn;
298
519
  domain;
299
520
  chainId;
521
+ issuer;
522
+ audience;
300
523
  nonceManager;
301
524
  now;
302
525
  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
- }
526
+ assertJwtSecretStrength(config.jwtSecret);
306
527
  this.sessionStore = config.sessionStore;
307
528
  this.jwtSecret = new TextEncoder().encode(config.jwtSecret);
308
529
  this.jwtExpiresIn = config.jwtExpiresIn ?? DEFAULT_EXPIRES_IN;
309
530
  this.domain = config.domain;
310
531
  this.chainId = config.chainId;
532
+ this.issuer = config.issuer;
533
+ this.audience = config.audience;
311
534
  this.nonceManager = new NonceManager(config.sessionStore);
312
535
  this.now = config.now ?? (() => /* @__PURE__ */ new Date());
313
536
  }
@@ -384,29 +607,59 @@ var AuthService = class {
384
607
  expiresAt
385
608
  };
386
609
  await this.sessionStore.createSession(session);
387
- const token = await new import_jose.SignJWT({
610
+ let signer = new import_jose.SignJWT({
388
611
  userAddress,
389
612
  chainId: this.chainId
390
- }).setProtectedHeader({ alg: "HS256" }).setJti(tokenId).setIssuedAt(Math.floor(issuedAt.getTime() / 1e3)).setExpirationTime(Math.floor(expiresAt.getTime() / 1e3)).sign(this.jwtSecret);
613
+ }).setProtectedHeader({ alg: "HS256" }).setJti(tokenId).setIssuedAt(Math.floor(issuedAt.getTime() / 1e3)).setExpirationTime(Math.floor(expiresAt.getTime() / 1e3));
614
+ if (this.issuer) signer = signer.setIssuer(this.issuer);
615
+ if (this.audience) signer = signer.setAudience(this.audience);
616
+ const token = await signer.sign(this.jwtSecret);
391
617
  return { token, userAddress, tokenId, expiresAt };
392
618
  }
393
619
  /** Revoke the session backing the given JWT (logout). */
394
620
  async logout(token) {
621
+ let payload;
395
622
  try {
396
- const { payload } = await (0, import_jose.jwtVerify)(token, this.jwtSecret, {
397
- clockTolerance: 60
623
+ const result = await (0, import_jose.jwtVerify)(token, this.jwtSecret, {
624
+ clockTolerance: 60,
398
625
  // allow logout right after expiry
626
+ ...this.issuer ? { issuer: this.issuer } : {},
627
+ ...this.audience ? { audience: this.audience } : {}
399
628
  });
400
- if (payload.jti) {
401
- await this.sessionStore.revokeSession(payload.jti);
402
- }
629
+ payload = result.payload;
403
630
  } 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);
631
+ if (err instanceof import_jose.errors.JWTExpired) {
632
+ const decoded = decodeExpiredJwtJti(token);
633
+ if (decoded.jti) {
634
+ try {
635
+ await this.sessionStore.revokeSession(decoded.jti);
636
+ } catch (storeErr) {
637
+ this.logSessionStoreError(storeErr);
638
+ }
639
+ }
640
+ return;
641
+ }
642
+ if (err instanceof import_jose.errors.JWSSignatureVerificationFailed || err instanceof import_jose.errors.JWSInvalid || err instanceof import_jose.errors.JWTInvalid) {
643
+ throw new AuthError("TOKEN_INVALID", "JWT verification failed");
644
+ }
645
+ throw new AuthError(
646
+ "TOKEN_INVALID",
647
+ `JWT verification failed: ${err instanceof Error ? err.message : String(err)}`
648
+ );
649
+ }
650
+ if (payload.jti) {
651
+ try {
652
+ await this.sessionStore.revokeSession(payload.jti);
653
+ } catch (storeErr) {
654
+ this.logSessionStoreError(storeErr);
407
655
  }
408
656
  }
409
657
  }
658
+ logSessionStoreError(err) {
659
+ const msg = err instanceof Error ? err.message : String(err);
660
+ if (msg.includes("not found")) return;
661
+ console.error("[PAFI] AuthService logout: session store error", err);
662
+ }
410
663
  /**
411
664
  * Verify a JWT and return the authenticated user context. Throws an
412
665
  * `AuthError` if the token is missing, malformed, expired, revoked, or
@@ -415,7 +668,10 @@ var AuthService = class {
415
668
  async verifyToken(token) {
416
669
  let payload;
417
670
  try {
418
- const result = await (0, import_jose.jwtVerify)(token, this.jwtSecret);
671
+ const result = await (0, import_jose.jwtVerify)(token, this.jwtSecret, {
672
+ ...this.issuer ? { issuer: this.issuer } : {},
673
+ ...this.audience ? { audience: this.audience } : {}
674
+ });
419
675
  payload = result.payload;
420
676
  } catch (err) {
421
677
  if (err instanceof import_jose.errors.JWTExpired) {
@@ -481,6 +737,70 @@ async function authenticateRequest(authHeader, authService) {
481
737
  return authService.verifyToken(token);
482
738
  }
483
739
 
740
+ // src/auth/rateLimiter.ts
741
+ var DEFAULT_LIMITS = {
742
+ auth_nonce: { max: 30, windowMs: 6e4 },
743
+ // 30 nonces/min ≈ 1 per 2s
744
+ auth_login: { max: 5, windowMs: 6e4 }
745
+ // 5 logins/min
746
+ };
747
+ var MemoryRateLimiter = class {
748
+ buckets = /* @__PURE__ */ new Map();
749
+ limits;
750
+ now;
751
+ constructor(config = {}) {
752
+ if (process.env.NODE_ENV === "production" && !process.env.PAFI_ALLOW_MEMORY_RATE_LIMITER_IN_PROD) {
753
+ console.warn(
754
+ "[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."
755
+ );
756
+ }
757
+ this.limits = {
758
+ ...DEFAULT_LIMITS,
759
+ ...config.limits ?? {}
760
+ };
761
+ this.now = config.now ?? (() => Date.now());
762
+ }
763
+ async consume(key, action) {
764
+ const limit = this.limits[action];
765
+ if (!limit) return { allowed: true };
766
+ const bucketKey = `${action}:${key}`;
767
+ const now = this.now();
768
+ const bucket = this.buckets.get(bucketKey);
769
+ if (!bucket || now - bucket.windowStartedAt >= limit.windowMs) {
770
+ this.buckets.set(bucketKey, { count: 1, windowStartedAt: now });
771
+ return { allowed: true };
772
+ }
773
+ if (bucket.count < limit.max) {
774
+ bucket.count += 1;
775
+ return { allowed: true };
776
+ }
777
+ const retryAfterMs = Math.max(
778
+ 0,
779
+ bucket.windowStartedAt + limit.windowMs - now
780
+ );
781
+ return { allowed: false, retryAfterMs };
782
+ }
783
+ /**
784
+ * Test helper — clear all buckets. Not part of `IRateLimiter`; only
785
+ * exposed on the in-memory impl for unit tests.
786
+ */
787
+ reset() {
788
+ this.buckets.clear();
789
+ }
790
+ };
791
+ var NoopRateLimiter = class {
792
+ warned = false;
793
+ async consume() {
794
+ if (!this.warned && process.env.NODE_ENV === "production") {
795
+ console.warn(
796
+ "[PAFI] NoopRateLimiter active \u2014 `/auth/nonce` and `/auth/login` are NOT throttled. Wire a `MemoryRateLimiter` (dev) or Redis-backed impl (prod) via `IssuerApiHandlersConfig.rateLimiter`."
797
+ );
798
+ this.warned = true;
799
+ }
800
+ return { allowed: true };
801
+ }
802
+ };
803
+
484
804
  // src/relay/types.ts
485
805
  var RelayError = class extends import_core.PafiSdkError {
486
806
  httpStatus = "unprocessable";
@@ -828,6 +1148,7 @@ var PointIndexer = class {
828
1148
  confirmations;
829
1149
  batchSize;
830
1150
  pollIntervalMs;
1151
+ onTickError;
831
1152
  running = false;
832
1153
  timer;
833
1154
  constructor(config) {
@@ -843,6 +1164,7 @@ var PointIndexer = class {
843
1164
  this.confirmations = BigInt(config.confirmations ?? DEFAULT_CONFIRMATIONS);
844
1165
  this.batchSize = BigInt(config.batchSize ?? Number(DEFAULT_BATCH_SIZE));
845
1166
  this.pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1167
+ if (config.onTickError) this.onTickError = config.onTickError;
846
1168
  }
847
1169
  // -------------------------------------------------------------------------
848
1170
  // Lifecycle
@@ -851,7 +1173,7 @@ var PointIndexer = class {
851
1173
  start() {
852
1174
  if (this.running) return;
853
1175
  this.running = true;
854
- void this.tick();
1176
+ this.tick().catch((err) => this.handleTickError(err));
855
1177
  }
856
1178
  /** Stop polling. Safe to call multiple times. */
857
1179
  stop() {
@@ -883,13 +1205,27 @@ var PointIndexer = class {
883
1205
  }
884
1206
  await this.processBlockRange(from, safeHead);
885
1207
  } catch (err) {
886
- console.error("[PAFI] PointIndexer tick error:", err);
1208
+ this.handleTickError(err);
887
1209
  }
888
1210
  this.scheduleNext();
889
1211
  }
1212
+ handleTickError(err) {
1213
+ if (this.onTickError) {
1214
+ try {
1215
+ this.onTickError(err);
1216
+ } catch {
1217
+ console.error("[PAFI] PointIndexer onTickError threw:", err);
1218
+ }
1219
+ } else {
1220
+ console.error("[PAFI] PointIndexer tick error:", err);
1221
+ }
1222
+ }
890
1223
  scheduleNext() {
891
1224
  if (!this.running) return;
892
- this.timer = setTimeout(() => void this.tick(), this.pollIntervalMs);
1225
+ this.timer = setTimeout(
1226
+ () => this.tick().catch((err) => this.handleTickError(err)),
1227
+ this.pollIntervalMs
1228
+ );
893
1229
  }
894
1230
  // -------------------------------------------------------------------------
895
1231
  // Block scanning
@@ -1009,6 +1345,7 @@ var BurnIndexer = class {
1009
1345
  confirmations;
1010
1346
  batchSize;
1011
1347
  pollIntervalMs;
1348
+ onTickError;
1012
1349
  matchLockId;
1013
1350
  running = false;
1014
1351
  timer;
@@ -1027,6 +1364,7 @@ var BurnIndexer = class {
1027
1364
  );
1028
1365
  this.batchSize = BigInt(config.batchSize ?? Number(DEFAULT_BATCH_SIZE2));
1029
1366
  this.pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS2;
1367
+ if (config.onTickError) this.onTickError = config.onTickError;
1030
1368
  if (!config.matchLockId) {
1031
1369
  throw new Error(
1032
1370
  "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 +1375,7 @@ var BurnIndexer = class {
1037
1375
  start() {
1038
1376
  if (this.running) return;
1039
1377
  this.running = true;
1040
- void this.tick();
1378
+ this.tick().catch((err) => this.handleTickError(err));
1041
1379
  }
1042
1380
  stop() {
1043
1381
  this.running = false;
@@ -1063,13 +1401,27 @@ var BurnIndexer = class {
1063
1401
  }
1064
1402
  await this.processBlockRange(from, safeHead);
1065
1403
  } catch (err) {
1066
- console.error("[PAFI] BurnIndexer tick error:", err);
1404
+ this.handleTickError(err);
1067
1405
  }
1068
1406
  this.scheduleNext();
1069
1407
  }
1408
+ handleTickError(err) {
1409
+ if (this.onTickError) {
1410
+ try {
1411
+ this.onTickError(err);
1412
+ } catch {
1413
+ console.error("[PAFI] BurnIndexer onTickError threw:", err);
1414
+ }
1415
+ } else {
1416
+ console.error("[PAFI] BurnIndexer tick error:", err);
1417
+ }
1418
+ }
1070
1419
  scheduleNext() {
1071
1420
  if (!this.running) return;
1072
- this.timer = setTimeout(() => void this.tick(), this.pollIntervalMs);
1421
+ this.timer = setTimeout(
1422
+ () => this.tick().catch((err) => this.handleTickError(err)),
1423
+ this.pollIntervalMs
1424
+ );
1073
1425
  }
1074
1426
  /**
1075
1427
  * Scan `[from, to]` inclusive for burn events. Callers can drive this
@@ -1162,10 +1514,13 @@ var IssuerApiHandlers = class {
1162
1514
  pafiWebUrl;
1163
1515
  feeManager;
1164
1516
  poolsProvider;
1517
+ redemption;
1518
+ rateLimiter;
1165
1519
  constructor(config) {
1166
1520
  this.authService = config.authService;
1167
1521
  this.ledger = config.ledger;
1168
1522
  this.provider = config.provider;
1523
+ this.rateLimiter = config.rateLimiter ?? new NoopRateLimiter();
1169
1524
  const raw = config.pointTokenAddresses && config.pointTokenAddresses.length > 0 ? config.pointTokenAddresses : config.pointTokenAddress ? [config.pointTokenAddress] : [];
1170
1525
  if (raw.length === 0) {
1171
1526
  throw new Error(
@@ -1179,17 +1534,64 @@ var IssuerApiHandlers = class {
1179
1534
  if (config.pafiWebUrl) this.pafiWebUrl = config.pafiWebUrl;
1180
1535
  if (config.feeManager) this.feeManager = config.feeManager;
1181
1536
  if (config.poolsProvider) this.poolsProvider = config.poolsProvider;
1537
+ if (config.redemption) this.redemption = config.redemption;
1182
1538
  }
1183
1539
  // =========================================================================
1184
1540
  // Public handlers (no auth required)
1185
1541
  // =========================================================================
1186
- /** `GET /auth/nonce` */
1187
- async handleGetNonce() {
1542
+ /**
1543
+ * `GET /auth/nonce`
1544
+ *
1545
+ * @param rateLimitKey Caller-side rate-limit key (typically client IP).
1546
+ * The HTTP layer (controller/middleware) extracts
1547
+ * this from the request and passes it through.
1548
+ * When omitted, no rate limit applies — production
1549
+ * callers SHOULD always pass a key.
1550
+ */
1551
+ async handleGetNonce(rateLimitKey) {
1552
+ if (rateLimitKey) {
1553
+ const result = await this.rateLimiter.consume(
1554
+ rateLimitKey,
1555
+ "auth_nonce"
1556
+ );
1557
+ if (!result.allowed) {
1558
+ throw new import_core3.ValidationError(
1559
+ "RATE_LIMIT_EXCEEDED",
1560
+ "handleGetNonce: too many requests",
1561
+ {
1562
+ retryAfterMs: result.retryAfterMs ?? 0,
1563
+ action: "auth_nonce"
1564
+ }
1565
+ );
1566
+ }
1567
+ }
1188
1568
  const nonce = await this.authService.getNonce();
1189
1569
  return { nonce };
1190
1570
  }
1191
- /** `POST /auth/login` */
1192
- async handleLogin(body) {
1571
+ /**
1572
+ * `POST /auth/login`
1573
+ *
1574
+ * @param body Login message + signature.
1575
+ * @param rateLimitKey Caller-side rate-limit key (typically client IP
1576
+ * or `body.userAddress` if known). See `handleGetNonce`.
1577
+ */
1578
+ async handleLogin(body, rateLimitKey) {
1579
+ if (rateLimitKey) {
1580
+ const result2 = await this.rateLimiter.consume(
1581
+ rateLimitKey,
1582
+ "auth_login"
1583
+ );
1584
+ if (!result2.allowed) {
1585
+ throw new import_core3.ValidationError(
1586
+ "RATE_LIMIT_EXCEEDED",
1587
+ "handleLogin: too many requests",
1588
+ {
1589
+ retryAfterMs: result2.retryAfterMs ?? 0,
1590
+ action: "auth_login"
1591
+ }
1592
+ );
1593
+ }
1594
+ }
1193
1595
  if (!body || typeof body.message !== "string" || body.message.length === 0 || typeof body.signature !== "string" || body.signature.length <= 2) {
1194
1596
  throw new import_core3.ValidationError(
1195
1597
  "INVALID_LOGIN_BODY",
@@ -1332,6 +1734,74 @@ var IssuerApiHandlers = class {
1332
1734
  // Note: legacy `handleClaim` (sync sponsored-claim returning calls[]) was
1333
1735
  // removed in 0.5.43 — callers should use `PTClaimHandler` directly or
1334
1736
  // wire `IssuerApiAdapter.claim()` which composes the full flow.
1737
+ /**
1738
+ * `GET /redemption/preview?pointToken=<addr>`
1739
+ *
1740
+ * Returns the headroom currently available to `userAddress` under the
1741
+ * configured RedemptionPolicy. Pure read — does not record anything.
1742
+ * Use this for UI to render "X PT redeemable now / next available at …".
1743
+ */
1744
+ async handleRedemptionPreview(userAddress, request) {
1745
+ if (!this.redemption) {
1746
+ throw new ConfigurationError(
1747
+ "REDEMPTION_NOT_CONFIGURED",
1748
+ "handleRedemptionPreview: redemption is not configured on this issuer"
1749
+ );
1750
+ }
1751
+ const tokenAddress = request.pointTokenAddress ? this.requireSupportedToken((0, import_viem6.getAddress)(request.pointTokenAddress), "handleRedemptionPreview") : void 0;
1752
+ const preview = await this.redemption.preview(
1753
+ (0, import_viem6.getAddress)(userAddress),
1754
+ tokenAddress
1755
+ );
1756
+ return preview;
1757
+ }
1758
+ /**
1759
+ * `POST /redemption/evaluate`
1760
+ *
1761
+ * Pre-flight check before the issuer signs a BurnRequest. Returns
1762
+ * { allowed, denial?, preview }. Caller (the burn-orchestrator) MUST
1763
+ * re-check on the actual initiate path — evaluate is read-only and a
1764
+ * caller could race two requests under the same headroom. The intended
1765
+ * write path is: evaluate → sign BurnRequest → reserve pending credit
1766
+ * → call `service.redemption.recordSuccessfulInitiate()`.
1767
+ */
1768
+ async handleRedemptionEvaluate(userAddress, request) {
1769
+ if (!this.redemption) {
1770
+ throw new ConfigurationError(
1771
+ "REDEMPTION_NOT_CONFIGURED",
1772
+ "handleRedemptionEvaluate: redemption is not configured on this issuer"
1773
+ );
1774
+ }
1775
+ if (request.amountPt <= 0n) {
1776
+ throw new import_core3.ValidationError(
1777
+ "INVALID_AMOUNT",
1778
+ "handleRedemptionEvaluate: amountPt must be positive",
1779
+ { amountPt: request.amountPt.toString() }
1780
+ );
1781
+ }
1782
+ const tokenAddress = request.pointTokenAddress ? this.requireSupportedToken((0, import_viem6.getAddress)(request.pointTokenAddress), "handleRedemptionEvaluate") : void 0;
1783
+ const decision = await this.redemption.evaluate(
1784
+ (0, import_viem6.getAddress)(userAddress),
1785
+ request.amountPt,
1786
+ tokenAddress
1787
+ );
1788
+ const response = {
1789
+ allowed: decision.allowed,
1790
+ preview: decision.preview
1791
+ };
1792
+ if (decision.denial) response.denial = decision.denial;
1793
+ return response;
1794
+ }
1795
+ requireSupportedToken(pointToken, handler) {
1796
+ if (!this.supportedTokens.has(pointToken)) {
1797
+ throw new import_core3.ValidationError(
1798
+ "UNSUPPORTED_POINT_TOKEN",
1799
+ `${handler}: unsupported pointToken ${pointToken}`,
1800
+ { requested: pointToken }
1801
+ );
1802
+ }
1803
+ return pointToken;
1804
+ }
1335
1805
  };
1336
1806
 
1337
1807
  // src/api/handlers/ptRedeemHandler.ts
@@ -1342,9 +1812,13 @@ var DEFAULT_SIG_DEADLINE_SEC = 15 * 60;
1342
1812
  var PTRedeemError = class extends import_core.PafiSdkError {
1343
1813
  httpStatus = "unprocessable";
1344
1814
  code;
1345
- constructor(code, message) {
1815
+ policyDenialCode;
1816
+ constructor(code, message, options) {
1346
1817
  super(message);
1347
1818
  this.code = code;
1819
+ if (options?.policyDenialCode) {
1820
+ this.policyDenialCode = options.policyDenialCode;
1821
+ }
1348
1822
  }
1349
1823
  };
1350
1824
  var PTRedeemHandler = class {
@@ -1360,6 +1834,7 @@ var PTRedeemHandler = class {
1360
1834
  redeemLockDurationMs;
1361
1835
  signatureDeadlineSeconds;
1362
1836
  now;
1837
+ redemptionService;
1363
1838
  /**
1364
1839
  * Per-user in-flight nonce guard (single-process only).
1365
1840
  *
@@ -1402,6 +1877,9 @@ var PTRedeemHandler = class {
1402
1877
  this.redeemLockDurationMs = config.redeemLockDurationMs ?? DEFAULT_REDEEM_LOCK_MS;
1403
1878
  this.signatureDeadlineSeconds = config.signatureDeadlineSeconds ?? DEFAULT_SIG_DEADLINE_SEC;
1404
1879
  this.now = config.now ?? (() => Date.now());
1880
+ if (config.redemptionService) {
1881
+ this.redemptionService = config.redemptionService;
1882
+ }
1405
1883
  }
1406
1884
  async handle(request) {
1407
1885
  if ((0, import_viem7.getAddress)(request.authenticatedAddress) !== (0, import_viem7.getAddress)(request.userAddress)) {
@@ -1413,6 +1891,21 @@ var PTRedeemHandler = class {
1413
1891
  if (request.amount <= 0n) {
1414
1892
  throw new PTRedeemError("INVALID_AMOUNT", "redeem amount must be positive");
1415
1893
  }
1894
+ if (this.redemptionService) {
1895
+ const decision = await this.redemptionService.evaluate(
1896
+ request.userAddress,
1897
+ request.amount,
1898
+ this.pointTokenAddress
1899
+ );
1900
+ if (!decision.allowed) {
1901
+ const denial = decision.denial;
1902
+ throw new PTRedeemError(
1903
+ "REDEMPTION_POLICY_DENIED",
1904
+ `redemption denied: ${denial.message}`,
1905
+ { policyDenialCode: denial.code }
1906
+ );
1907
+ }
1908
+ }
1416
1909
  let burnNonce;
1417
1910
  try {
1418
1911
  burnNonce = await this.provider.readContract({
@@ -1566,6 +2059,15 @@ var PTRedeemHandler = class {
1566
2059
  netCreditAmount: request.amount
1567
2060
  };
1568
2061
  }
2062
+ if (this.redemptionService) {
2063
+ await this.redemptionService.recordSuccessfulInitiate({
2064
+ user: request.userAddress,
2065
+ amountPt: request.amount,
2066
+ pointTokenAddress: this.pointTokenAddress,
2067
+ reservationId: sponsoredLockId
2068
+ }).catch(() => {
2069
+ });
2070
+ }
1569
2071
  return {
1570
2072
  lockId: sponsoredLockId,
1571
2073
  userOp: sponsoredUserOp,
@@ -2432,31 +2934,6 @@ async function handleDelegateSubmit(params) {
2432
2934
  return { userOpHash: result.userOpHash };
2433
2935
  }
2434
2936
 
2435
- // src/api/errorMapper.ts
2436
- function createSdkErrorMapper(factories) {
2437
- return (err) => {
2438
- if (!(err instanceof import_core.PafiSdkError)) {
2439
- throw err;
2440
- }
2441
- const body = {
2442
- code: err.code,
2443
- message: err.message,
2444
- details: err.details,
2445
- safeToRetry: err.safeToRetry
2446
- };
2447
- switch (err.httpStatus) {
2448
- case "not_found":
2449
- throw factories.notFound(body);
2450
- case "forbidden":
2451
- throw factories.forbidden(body);
2452
- case "unprocessable":
2453
- throw factories.unprocessable(body);
2454
- case "service_unavailable":
2455
- throw factories.serviceUnavailable(body);
2456
- }
2457
- };
2458
- }
2459
-
2460
2937
  // src/api/issuerApiAdapter.ts
2461
2938
  var import_node_crypto3 = require("crypto");
2462
2939
  var import_viem11 = require("viem");
@@ -3289,6 +3766,7 @@ var BalanceAggregator = class {
3289
3766
  };
3290
3767
 
3291
3768
  // src/pafi-backend/client.ts
3769
+ var import_core17 = require("@pafi-dev/core");
3292
3770
  function serializeBigInt(_key, value) {
3293
3771
  return typeof value === "bigint" ? value.toString(10) : value;
3294
3772
  }
@@ -3297,10 +3775,13 @@ function sleep(ms) {
3297
3775
  }
3298
3776
  var PafiBackendClient = class {
3299
3777
  config;
3778
+ baseUrl;
3300
3779
  constructor(config) {
3301
- if (!config.url) throw new Error("PafiBackendClient: url is required");
3780
+ if (!config.chainId) throw new Error("PafiBackendClient: chainId is required");
3302
3781
  if (!config.issuerId) throw new Error("PafiBackendClient: issuerId is required");
3782
+ if (!config.apiKey) throw new Error("PafiBackendClient: apiKey is required");
3303
3783
  this.config = config;
3784
+ this.baseUrl = (0, import_core17.getPafiServiceUrls)(config.chainId).sponsorRelayer;
3304
3785
  }
3305
3786
  async requestSponsorship(request) {
3306
3787
  const maxAttempts = this.config.retry?.maxAttempts ?? 1;
@@ -3335,7 +3816,7 @@ var PafiBackendClient = class {
3335
3816
  */
3336
3817
  async getUserOpReceipt(userOpHash) {
3337
3818
  const fetchFn = this.config.fetchImpl ?? fetch;
3338
- const url = `${this.config.url}/bundler/receipt`;
3819
+ const url = `${this.baseUrl}/bundler/receipt`;
3339
3820
  let response;
3340
3821
  try {
3341
3822
  response = await fetchFn(url, {
@@ -3374,7 +3855,7 @@ var PafiBackendClient = class {
3374
3855
  }
3375
3856
  async relayUserOperation(request) {
3376
3857
  const fetchFn = this.config.fetchImpl ?? fetch;
3377
- const url = `${this.config.url}/bundler/relay`;
3858
+ const url = `${this.baseUrl}/bundler/relay`;
3378
3859
  let response;
3379
3860
  try {
3380
3861
  response = await fetchFn(url, {
@@ -3408,7 +3889,7 @@ var PafiBackendClient = class {
3408
3889
  }
3409
3890
  async _doRequest(request) {
3410
3891
  const fetchFn = this.config.fetchImpl ?? fetch;
3411
- const url = `${this.config.url}/paymaster/sponsor`;
3892
+ const url = `${this.baseUrl}/paymaster/sponsor`;
3412
3893
  const body = JSON.stringify(request, serializeBigInt);
3413
3894
  let response;
3414
3895
  try {
@@ -3463,7 +3944,313 @@ var PafiBackendClient = class {
3463
3944
 
3464
3945
  // src/config.ts
3465
3946
  var import_viem14 = require("viem");
3466
- var import_core17 = require("@pafi-dev/core");
3947
+ var import_core19 = require("@pafi-dev/core");
3948
+
3949
+ // src/redemption/evaluator.ts
3950
+ var SECONDS_PER_DAY = 24 * 60 * 60;
3951
+ function evaluateRedemption(input) {
3952
+ const { policy, history, amountPt, nowUnixSec, policySource } = input;
3953
+ const dailyRemaining = policy.dailyLimitPt > history.redeemedLast24hPt ? policy.dailyLimitPt - history.redeemedLast24hPt : 0n;
3954
+ const cooldownUntilUnixSec = history.lastRedeemedAtUnixSec !== null ? history.lastRedeemedAtUnixSec + policy.cooldownSec : null;
3955
+ const inCooldown = cooldownUntilUnixSec !== null && cooldownUntilUnixSec > nowUnixSec;
3956
+ const activeBlackout = findActiveBlackout(policy.blackoutWindows, nowUnixSec);
3957
+ const nextBlackoutEnd = nextBlackoutEndAfter(
3958
+ policy.blackoutWindows,
3959
+ nowUnixSec
3960
+ );
3961
+ let availableAmountPt = 0n;
3962
+ if (!inCooldown && !activeBlackout) {
3963
+ const headroom = dailyRemaining < policy.perTxMaxPt ? dailyRemaining : policy.perTxMaxPt;
3964
+ availableAmountPt = headroom >= policy.perTxMinPt ? headroom : 0n;
3965
+ }
3966
+ const preview = {
3967
+ availableAmountPt,
3968
+ dailyRemainingPt: dailyRemaining,
3969
+ cooldownUntilUnixSec: inCooldown ? cooldownUntilUnixSec : null,
3970
+ nextBlackoutEndsAtUnixSec: activeBlackout ? activeBlackout.endUnixSec : nextBlackoutEnd,
3971
+ perTxMinPt: policy.perTxMinPt,
3972
+ perTxMaxPt: policy.perTxMaxPt,
3973
+ policyVersion: policy.version,
3974
+ policySource
3975
+ };
3976
+ if (amountPt <= 0n) {
3977
+ return { allowed: false, preview, denial: rejectAmountBelowMin(policy) };
3978
+ }
3979
+ const denial = firstDenial({
3980
+ amountPt,
3981
+ policy,
3982
+ dailyRemaining,
3983
+ inCooldown,
3984
+ cooldownUntilUnixSec,
3985
+ activeBlackout
3986
+ });
3987
+ if (denial) return { allowed: false, denial, preview };
3988
+ return { allowed: true, preview };
3989
+ }
3990
+ function firstDenial(args) {
3991
+ const { amountPt, policy, dailyRemaining, inCooldown, cooldownUntilUnixSec, activeBlackout } = args;
3992
+ if (activeBlackout) {
3993
+ return {
3994
+ code: "BLACKOUT_WINDOW",
3995
+ message: `Redemption is blocked until ${new Date(
3996
+ activeBlackout.endUnixSec * 1e3
3997
+ ).toISOString()}${activeBlackout.reason ? ` (${activeBlackout.reason})` : ""}`
3998
+ };
3999
+ }
4000
+ if (inCooldown && cooldownUntilUnixSec !== null) {
4001
+ return {
4002
+ code: "COOLDOWN_ACTIVE",
4003
+ message: `Cooldown active until ${new Date(
4004
+ cooldownUntilUnixSec * 1e3
4005
+ ).toISOString()}`
4006
+ };
4007
+ }
4008
+ if (amountPt < policy.perTxMinPt) {
4009
+ return {
4010
+ code: "AMOUNT_BELOW_MIN",
4011
+ message: `amount ${amountPt} below per-tx minimum ${policy.perTxMinPt}`
4012
+ };
4013
+ }
4014
+ if (amountPt > policy.perTxMaxPt) {
4015
+ return {
4016
+ code: "AMOUNT_ABOVE_MAX",
4017
+ message: `amount ${amountPt} above per-tx maximum ${policy.perTxMaxPt}`
4018
+ };
4019
+ }
4020
+ if (amountPt > dailyRemaining) {
4021
+ return {
4022
+ code: "DAILY_LIMIT_EXCEEDED",
4023
+ message: `amount ${amountPt} exceeds daily remaining ${dailyRemaining}`
4024
+ };
4025
+ }
4026
+ return null;
4027
+ }
4028
+ function rejectAmountBelowMin(policy) {
4029
+ return {
4030
+ code: "AMOUNT_BELOW_MIN",
4031
+ message: `amount must be >= ${policy.perTxMinPt}`
4032
+ };
4033
+ }
4034
+ function findActiveBlackout(windows, nowUnixSec) {
4035
+ for (const w of windows) {
4036
+ if (w.startUnixSec <= nowUnixSec && nowUnixSec < w.endUnixSec) return w;
4037
+ }
4038
+ return null;
4039
+ }
4040
+ function nextBlackoutEndAfter(windows, nowUnixSec) {
4041
+ let earliest = null;
4042
+ for (const w of windows) {
4043
+ if (w.endUnixSec > nowUnixSec) {
4044
+ if (earliest === null || w.endUnixSec < earliest) earliest = w.endUnixSec;
4045
+ }
4046
+ }
4047
+ return earliest;
4048
+ }
4049
+ var REDEMPTION_HISTORY_WINDOW_SEC = SECONDS_PER_DAY;
4050
+
4051
+ // src/redemption/settlementClient.ts
4052
+ var import_core18 = require("@pafi-dev/core");
4053
+ var DEFAULT_TIMEOUT_MS = 1e3;
4054
+ var SettlementClient = class {
4055
+ config;
4056
+ constructor(config) {
4057
+ if (!config.chainId) throw new Error("SettlementClient: chainId is required");
4058
+ if (!config.issuerId) throw new Error("SettlementClient: issuerId is required");
4059
+ if (!config.apiKey) throw new Error("SettlementClient: apiKey is required");
4060
+ this.config = {
4061
+ baseUrl: (0, import_core18.getPafiServiceUrls)(config.chainId).issuerApi.replace(/\/+$/, ""),
4062
+ issuerId: config.issuerId,
4063
+ apiKey: config.apiKey,
4064
+ fetchTimeoutMs: config.fetchTimeoutMs ?? DEFAULT_TIMEOUT_MS,
4065
+ fetchImpl: config.fetchImpl
4066
+ };
4067
+ }
4068
+ async fetchPolicy() {
4069
+ const fetchFn = this.config.fetchImpl ?? fetch;
4070
+ const url = `${this.config.baseUrl}/issuers/${encodeURIComponent(this.config.issuerId)}/redemption-policy`;
4071
+ const controller = new AbortController();
4072
+ const timer = setTimeout(
4073
+ () => controller.abort(),
4074
+ this.config.fetchTimeoutMs
4075
+ );
4076
+ let response;
4077
+ try {
4078
+ response = await fetchFn(url, {
4079
+ method: "GET",
4080
+ headers: {
4081
+ "Content-Type": "application/json",
4082
+ Authorization: `Bearer ${this.config.apiKey}`,
4083
+ "X-Issuer-Id": this.config.issuerId
4084
+ },
4085
+ signal: controller.signal
4086
+ });
4087
+ } catch (err) {
4088
+ const isAbort = err instanceof Error && (err.name === "AbortError" || /aborted|timeout/i.test(err.message ?? ""));
4089
+ return { ok: false, reason: isAbort ? "TIMEOUT" : "NETWORK" };
4090
+ } finally {
4091
+ clearTimeout(timer);
4092
+ }
4093
+ if (response.status === 404) {
4094
+ return { ok: false, reason: "NOT_FOUND", status: 404 };
4095
+ }
4096
+ if (response.status === 401 || response.status === 403) {
4097
+ return { ok: false, reason: "UNAUTHORIZED", status: response.status };
4098
+ }
4099
+ if (!response.ok) {
4100
+ return { ok: false, reason: "SERVER_ERROR", status: response.status };
4101
+ }
4102
+ let raw;
4103
+ try {
4104
+ raw = await response.json();
4105
+ } catch {
4106
+ return { ok: false, reason: "INVALID_RESPONSE", status: response.status };
4107
+ }
4108
+ const parsed = parsePolicyDto(raw);
4109
+ if (!parsed) {
4110
+ return { ok: false, reason: "INVALID_RESPONSE", status: response.status };
4111
+ }
4112
+ return { ok: true, policy: parsed };
4113
+ }
4114
+ };
4115
+ function parsePolicyDto(raw) {
4116
+ if (!raw || typeof raw !== "object") return null;
4117
+ const dto = raw;
4118
+ 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)) {
4119
+ return null;
4120
+ }
4121
+ try {
4122
+ return {
4123
+ issuerId: dto.issuerId,
4124
+ dailyLimitPt: BigInt(dto.dailyLimitPt),
4125
+ cooldownSec: dto.cooldownSec,
4126
+ perTxMinPt: BigInt(dto.perTxMinPt),
4127
+ perTxMaxPt: BigInt(dto.perTxMaxPt),
4128
+ blackoutWindows: dto.blackoutWindows.map(normalizeBlackout).filter((b) => b !== null),
4129
+ version: dto.version
4130
+ };
4131
+ } catch {
4132
+ return null;
4133
+ }
4134
+ }
4135
+ function normalizeBlackout(raw) {
4136
+ if (!raw || typeof raw !== "object") return null;
4137
+ const win = raw;
4138
+ if (typeof win.startUnixSec !== "number" || typeof win.endUnixSec !== "number" || win.startUnixSec >= win.endUnixSec) {
4139
+ return null;
4140
+ }
4141
+ return {
4142
+ startUnixSec: win.startUnixSec,
4143
+ endUnixSec: win.endUnixSec,
4144
+ reason: typeof win.reason === "string" ? win.reason : void 0
4145
+ };
4146
+ }
4147
+
4148
+ // src/redemption/defaults.ts
4149
+ var PT_DECIMALS = 10n ** 18n;
4150
+ var DEFAULT_REDEMPTION_POLICY = {
4151
+ issuerId: "default",
4152
+ dailyLimitPt: 1000n * PT_DECIMALS,
4153
+ cooldownSec: 60,
4154
+ perTxMinPt: 1n * PT_DECIMALS,
4155
+ perTxMaxPt: 500n * PT_DECIMALS,
4156
+ blackoutWindows: [],
4157
+ version: "default-v1"
4158
+ };
4159
+ function defaultPolicyFor(issuerId) {
4160
+ return { ...DEFAULT_REDEMPTION_POLICY, issuerId };
4161
+ }
4162
+
4163
+ // src/redemption/policyProvider.ts
4164
+ var DEFAULT_CACHE_TTL_MS3 = 5 * 60 * 1e3;
4165
+ var PolicyProvider = class {
4166
+ client;
4167
+ issuerId;
4168
+ cacheTtlMs;
4169
+ now;
4170
+ cache = null;
4171
+ inflight = null;
4172
+ constructor(config) {
4173
+ this.client = new SettlementClient(config);
4174
+ this.issuerId = config.issuerId;
4175
+ this.cacheTtlMs = config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS3;
4176
+ this.now = config.now ?? (() => Date.now());
4177
+ }
4178
+ async getPolicy() {
4179
+ const fresh = this.readCache();
4180
+ if (fresh) return { policy: fresh, source: "cache" };
4181
+ if (this.inflight) return this.inflight;
4182
+ this.inflight = this.fetchAndStore().finally(() => {
4183
+ this.inflight = null;
4184
+ });
4185
+ return this.inflight;
4186
+ }
4187
+ /** Drop cached policy. Next getPolicy() will refetch. */
4188
+ invalidate() {
4189
+ this.cache = null;
4190
+ }
4191
+ readCache() {
4192
+ if (!this.cache) return null;
4193
+ if (this.cache.expiresAtMs <= this.now()) {
4194
+ this.cache = null;
4195
+ return null;
4196
+ }
4197
+ return this.cache.policy;
4198
+ }
4199
+ async fetchAndStore() {
4200
+ const result = await this.client.fetchPolicy();
4201
+ if (result.ok) {
4202
+ this.cache = {
4203
+ policy: result.policy,
4204
+ expiresAtMs: this.now() + this.cacheTtlMs
4205
+ };
4206
+ return { policy: result.policy, source: "settlement" };
4207
+ }
4208
+ return { policy: defaultPolicyFor(this.issuerId), source: "default" };
4209
+ }
4210
+ };
4211
+
4212
+ // src/redemption/service.ts
4213
+ var RedemptionService = class {
4214
+ policyProvider;
4215
+ historyStore;
4216
+ nowUnixSec;
4217
+ constructor(config) {
4218
+ this.policyProvider = config.policyProvider instanceof PolicyProvider ? config.policyProvider : new PolicyProvider(config.policyProvider);
4219
+ this.historyStore = config.historyStore;
4220
+ this.nowUnixSec = config.nowUnixSec ?? (() => Math.floor(Date.now() / 1e3));
4221
+ }
4222
+ async preview(user, pointTokenAddress) {
4223
+ const decision = await this.evaluate(user, 0n, pointTokenAddress);
4224
+ return decision.preview;
4225
+ }
4226
+ async evaluate(user, amountPt, pointTokenAddress) {
4227
+ const { policy, source } = await this.policyProvider.getPolicy();
4228
+ const now = this.nowUnixSec();
4229
+ const [redeemedLast24hPt, lastRedeemedAtUnixSec] = await Promise.all([
4230
+ this.historyStore.sumRedeemedSince(
4231
+ user,
4232
+ now - REDEMPTION_HISTORY_WINDOW_SEC,
4233
+ pointTokenAddress
4234
+ ),
4235
+ this.historyStore.getLastRedeemedAtUnixSec(user, pointTokenAddress)
4236
+ ]);
4237
+ return evaluateRedemption({
4238
+ policy,
4239
+ policySource: source,
4240
+ history: { redeemedLast24hPt, lastRedeemedAtUnixSec },
4241
+ amountPt,
4242
+ nowUnixSec: now
4243
+ });
4244
+ }
4245
+ async recordSuccessfulInitiate(entry) {
4246
+ await this.historyStore.recordRedemption({
4247
+ ...entry,
4248
+ unixSec: this.nowUnixSec()
4249
+ });
4250
+ }
4251
+ };
4252
+
4253
+ // src/config.ts
3467
4254
  function createIssuerService(config) {
3468
4255
  if (!config.provider) {
3469
4256
  throw new Error("createIssuerService: provider is required");
@@ -3533,7 +4320,7 @@ function createIssuerService(config) {
3533
4320
  indexers.set(tokenAddress, new PointIndexer(indexerConfig));
3534
4321
  }
3535
4322
  const firstIndexer = indexers.get(tokenAddresses[0]);
3536
- const chainAddresses = (0, import_core17.getContractAddresses)(config.chainId);
4323
+ const chainAddresses = (0, import_core19.getContractAddresses)(config.chainId);
3537
4324
  const resolvedContracts = {
3538
4325
  batchExecutor: chainAddresses.batchExecutor,
3539
4326
  usdt: chainAddresses.usdt,
@@ -3542,6 +4329,25 @@ function createIssuerService(config) {
3542
4329
  pafiHook: chainAddresses.pafiHook,
3543
4330
  ...config.contracts
3544
4331
  };
4332
+ let redemption;
4333
+ if (config.redemption) {
4334
+ const policyConfig = {
4335
+ chainId: config.chainId,
4336
+ issuerId: config.redemption.issuerId,
4337
+ apiKey: config.redemption.apiKey
4338
+ };
4339
+ if (config.redemption.fetchImpl) policyConfig.fetchImpl = config.redemption.fetchImpl;
4340
+ if (config.redemption.fetchTimeoutMs !== void 0) {
4341
+ policyConfig.fetchTimeoutMs = config.redemption.fetchTimeoutMs;
4342
+ }
4343
+ if (config.redemption.cacheTtlMs !== void 0) {
4344
+ policyConfig.cacheTtlMs = config.redemption.cacheTtlMs;
4345
+ }
4346
+ redemption = new RedemptionService({
4347
+ policyProvider: new PolicyProvider(policyConfig),
4348
+ historyStore: config.redemption.historyStore
4349
+ });
4350
+ }
3545
4351
  const handlersConfig = {
3546
4352
  authService,
3547
4353
  ledger,
@@ -3552,6 +4358,7 @@ function createIssuerService(config) {
3552
4358
  };
3553
4359
  if (feeManager) handlersConfig.feeManager = feeManager;
3554
4360
  if (config.poolsProvider) handlersConfig.poolsProvider = config.poolsProvider;
4361
+ if (redemption) handlersConfig.redemption = redemption;
3555
4362
  const handlers = new IssuerApiHandlers(handlersConfig);
3556
4363
  if (config.indexer?.autoStart) {
3557
4364
  for (const idx of indexers.values()) {
@@ -3567,13 +4374,14 @@ function createIssuerService(config) {
3567
4374
  fee: feeManager,
3568
4375
  indexers,
3569
4376
  indexer: firstIndexer,
3570
- api: handlers
4377
+ api: handlers,
4378
+ redemption
3571
4379
  };
3572
4380
  }
3573
4381
 
3574
4382
  // src/issuer-state/validator.ts
3575
4383
  var import_viem15 = require("viem");
3576
- var import_core18 = require("@pafi-dev/core");
4384
+ var import_core20 = require("@pafi-dev/core");
3577
4385
  var ISSUER_RECORD_TTL_MS = 3e4;
3578
4386
  var IssuerStateValidator = class _IssuerStateValidator {
3579
4387
  constructor(provider, registryAddress) {
@@ -3590,7 +4398,7 @@ var IssuerStateValidator = class _IssuerStateValidator {
3590
4398
  * `CONTRACT_ADDRESSES` map for the given chain.
3591
4399
  */
3592
4400
  static forChain(provider, chainId) {
3593
- const { issuerRegistry } = (0, import_core18.getContractAddresses)(chainId);
4401
+ const { issuerRegistry } = (0, import_core20.getContractAddresses)(chainId);
3594
4402
  return new _IssuerStateValidator(provider, issuerRegistry);
3595
4403
  }
3596
4404
  /**
@@ -3619,7 +4427,7 @@ var IssuerStateValidator = class _IssuerStateValidator {
3619
4427
  if (cached) return cached;
3620
4428
  const issuer = await this.provider.readContract({
3621
4429
  address: key,
3622
- abi: import_core18.POINT_TOKEN_V2_ABI,
4430
+ abi: import_core20.POINT_TOKEN_V2_ABI,
3623
4431
  functionName: "issuer"
3624
4432
  });
3625
4433
  this.pointTokenIssuerCache.set(key, (0, import_viem15.getAddress)(issuer));
@@ -3700,13 +4508,13 @@ var IssuerStateValidator = class _IssuerStateValidator {
3700
4508
  const [issuerTuple, totalSupply] = await Promise.all([
3701
4509
  this.provider.readContract({
3702
4510
  address: this.registryAddress,
3703
- abi: import_core18.issuerRegistryGetIssuerFlatAbi,
4511
+ abi: import_core20.issuerRegistryGetIssuerFlatAbi,
3704
4512
  functionName: "getIssuer",
3705
4513
  args: [issuerAddr]
3706
4514
  }),
3707
4515
  this.provider.readContract({
3708
4516
  address: tokenAddr,
3709
- abi: import_core18.POINT_TOKEN_V2_ABI,
4517
+ abi: import_core20.POINT_TOKEN_V2_ABI,
3710
4518
  functionName: "totalSupply"
3711
4519
  })
3712
4520
  ]);
@@ -3727,8 +4535,44 @@ var IssuerStateValidator = class _IssuerStateValidator {
3727
4535
  }
3728
4536
  };
3729
4537
 
4538
+ // src/redemption/memoryHistoryStore.ts
4539
+ var MemoryRedemptionHistoryStore = class {
4540
+ entries = [];
4541
+ async sumRedeemedSince(user, sinceUnixSec, pointTokenAddress) {
4542
+ const userKey = user.toLowerCase();
4543
+ const tokenKey = pointTokenAddress?.toLowerCase() ?? null;
4544
+ let total = 0n;
4545
+ for (const e of this.entries) {
4546
+ if (e.user !== userKey) continue;
4547
+ if (e.unixSec < sinceUnixSec) continue;
4548
+ if (tokenKey !== null && e.pointTokenAddress !== tokenKey) continue;
4549
+ total += e.amountPt;
4550
+ }
4551
+ return total;
4552
+ }
4553
+ async getLastRedeemedAtUnixSec(user, pointTokenAddress) {
4554
+ const userKey = user.toLowerCase();
4555
+ const tokenKey = pointTokenAddress?.toLowerCase() ?? null;
4556
+ let latest = null;
4557
+ for (const e of this.entries) {
4558
+ if (e.user !== userKey) continue;
4559
+ if (tokenKey !== null && e.pointTokenAddress !== tokenKey) continue;
4560
+ if (latest === null || e.unixSec > latest) latest = e.unixSec;
4561
+ }
4562
+ return latest;
4563
+ }
4564
+ async recordRedemption(entry) {
4565
+ this.entries.push({
4566
+ user: entry.user.toLowerCase(),
4567
+ amountPt: entry.amountPt,
4568
+ pointTokenAddress: entry.pointTokenAddress?.toLowerCase() ?? null,
4569
+ unixSec: entry.unixSec
4570
+ });
4571
+ }
4572
+ };
4573
+
3730
4574
  // src/index.ts
3731
- var PAFI_ISSUER_SDK_VERSION = true ? "0.7.8" : "dev";
4575
+ var PAFI_ISSUER_SDK_VERSION = true ? "0.9.0" : "dev";
3732
4576
  // Annotate the CommonJS export names for ESM import in node:
3733
4577
  0 && (module.exports = {
3734
4578
  AdapterMisconfiguredError,
@@ -3739,6 +4583,7 @@ var PAFI_ISSUER_SDK_VERSION = true ? "0.7.8" : "dev";
3739
4583
  BundlerRejectedError,
3740
4584
  BurnIndexer,
3741
4585
  ConfigurationError,
4586
+ DEFAULT_REDEMPTION_POLICY,
3742
4587
  DefaultPolicyEngine,
3743
4588
  FeeManager,
3744
4589
  InMemoryCursorStore,
@@ -3748,8 +4593,11 @@ var PAFI_ISSUER_SDK_VERSION = true ? "0.7.8" : "dev";
3748
4593
  IssuerStateValidator,
3749
4594
  LockNotFoundError,
3750
4595
  MemoryPendingUserOpStore,
4596
+ MemoryRateLimiter,
4597
+ MemoryRedemptionHistoryStore,
3751
4598
  MemorySessionStore,
3752
4599
  NonceManager,
4600
+ NoopRateLimiter,
3753
4601
  PAFI_ISSUER_SDK_VERSION,
3754
4602
  PAFI_SUBGRAPH_URL,
3755
4603
  PTClaimError,
@@ -3764,21 +4612,34 @@ var PAFI_ISSUER_SDK_VERSION = true ? "0.7.8" : "dev";
3764
4612
  PerpDepositError,
3765
4613
  PerpDepositHandler,
3766
4614
  PointIndexer,
4615
+ PolicyProvider,
4616
+ REDEMPTION_HISTORY_WINDOW_SEC,
4617
+ RedemptionService,
3767
4618
  RelayError,
3768
4619
  RelayService,
4620
+ SDK_ERROR_HTTP_STATUS_CODE,
4621
+ SettlementClient,
3769
4622
  ValidationError,
3770
4623
  authenticateRequest,
4624
+ buildErrorEnvelope,
4625
+ buildSdkErrorBody,
3771
4626
  createIssuerService,
3772
4627
  createNativePtQuoter,
3773
4628
  createSdkErrorMapper,
3774
4629
  createSubgraphNativeUsdtQuoter,
3775
4630
  createSubgraphPoolsProvider,
4631
+ defaultErrorTypeForStatus,
4632
+ defaultPolicyFor,
4633
+ evaluateRedemption,
3776
4634
  handleClaimStatus,
3777
4635
  handleDelegateSubmit,
3778
4636
  handleMobilePrepare,
3779
4637
  handleMobileSubmit,
3780
4638
  handleRedeemStatus,
3781
4639
  mergePaymasterFields,
4640
+ payloadFromGenericError,
4641
+ payloadFromHttpException,
4642
+ payloadFromPafiSdkError,
3782
4643
  prepareMobileUserOp,
3783
4644
  relayUserOp,
3784
4645
  requestPaymaster,