@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.js CHANGED
@@ -1,19 +1,17 @@
1
- // src/errors.ts
1
+ import "./chunk-R4FYJZ2N.js";
2
2
  import {
3
- PafiSdkError
4
- } from "@pafi-dev/core";
5
- import { PafiSdkError as PafiSdkError2 } from "@pafi-dev/core";
6
- import { ValidationError } from "@pafi-dev/core";
7
- var ConfigurationError = class extends PafiSdkError2 {
8
- httpStatus = "service_unavailable";
9
- code;
10
- details;
11
- constructor(code, message, details) {
12
- super(message);
13
- this.code = code;
14
- this.details = details;
15
- }
16
- };
3
+ ConfigurationError,
4
+ PafiSdkError,
5
+ SDK_ERROR_HTTP_STATUS_CODE,
6
+ ValidationError,
7
+ buildErrorEnvelope,
8
+ buildSdkErrorBody,
9
+ createSdkErrorMapper,
10
+ defaultErrorTypeForStatus,
11
+ payloadFromGenericError,
12
+ payloadFromHttpException,
13
+ payloadFromPafiSdkError
14
+ } from "./chunk-U3WMORJG.js";
17
15
 
18
16
  // src/policy/defaultPolicy.ts
19
17
  var DefaultPolicyEngine = class {
@@ -85,9 +83,9 @@ var MemorySessionStore = class {
85
83
  nonceTtlMs;
86
84
  now;
87
85
  constructor(opts = {}) {
88
- if (process.env.NODE_ENV === "production") {
89
- console.error(
90
- "[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."
86
+ if (process.env.NODE_ENV === "production" && !opts.dangerouslyAllowMemoryStoreInProduction) {
87
+ throw new Error(
88
+ "[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`."
91
89
  );
92
90
  }
93
91
  this.nonceTtlMs = opts.nonceTtlMs ?? DEFAULT_NONCE_TTL_MS;
@@ -214,6 +212,64 @@ var AuthError = class extends PafiSdkError {
214
212
  };
215
213
 
216
214
  // src/auth/loginVerifier.ts
215
+ function assertJwtSecretStrength(secret) {
216
+ if (!secret) {
217
+ throw new Error(
218
+ "AuthService: jwtSecret is required. Generate via `node -e \"console.log(require('crypto').randomBytes(32).toString('hex'))\"`"
219
+ );
220
+ }
221
+ if (secret.length < 32) {
222
+ throw new Error(
223
+ `AuthService: jwtSecret too short (${secret.length} chars; need \u2265 32). HS256 brute-force becomes feasible below this threshold.`
224
+ );
225
+ }
226
+ const uniqueChars = new Set(secret).size;
227
+ if (uniqueChars < 16) {
228
+ throw new Error(
229
+ `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')\`.`
230
+ );
231
+ }
232
+ const entropy = shannonEntropyBitsPerChar(secret);
233
+ if (entropy < 3.5) {
234
+ throw new Error(
235
+ `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).`
236
+ );
237
+ }
238
+ for (let period = 1; period <= secret.length / 4; period++) {
239
+ if (secret.length % period !== 0) continue;
240
+ const head = secret.slice(0, period);
241
+ if (secret === head.repeat(secret.length / period)) {
242
+ throw new Error(
243
+ `AuthService: jwtSecret is a repeating pattern of period ${period}. Use \`crypto.randomBytes(32).toString('hex')\`.`
244
+ );
245
+ }
246
+ }
247
+ }
248
+ function shannonEntropyBitsPerChar(s) {
249
+ const counts = /* @__PURE__ */ new Map();
250
+ for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1);
251
+ const len = s.length;
252
+ let h = 0;
253
+ for (const n of counts.values()) {
254
+ const p = n / len;
255
+ h -= p * Math.log2(p);
256
+ }
257
+ return h;
258
+ }
259
+ function decodeExpiredJwtJti(token) {
260
+ try {
261
+ const parts = token.split(".");
262
+ if (parts.length !== 3) return {};
263
+ const payloadB64 = parts[1];
264
+ if (!payloadB64) return {};
265
+ const padded = payloadB64.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((payloadB64.length + 3) % 4);
266
+ const json = Buffer.from(padded, "base64").toString("utf-8");
267
+ const claims = JSON.parse(json);
268
+ return typeof claims.jti === "string" ? { jti: claims.jti } : {};
269
+ } catch {
270
+ return {};
271
+ }
272
+ }
217
273
  var DEFAULT_EXPIRES_IN = "24h";
218
274
  var AuthService = class {
219
275
  sessionStore;
@@ -221,17 +277,19 @@ var AuthService = class {
221
277
  jwtExpiresIn;
222
278
  domain;
223
279
  chainId;
280
+ issuer;
281
+ audience;
224
282
  nonceManager;
225
283
  now;
226
284
  constructor(config) {
227
- if (!config.jwtSecret || config.jwtSecret.length < 32) {
228
- throw new Error("AuthService: jwtSecret must be at least 32 characters for HS256 security");
229
- }
285
+ assertJwtSecretStrength(config.jwtSecret);
230
286
  this.sessionStore = config.sessionStore;
231
287
  this.jwtSecret = new TextEncoder().encode(config.jwtSecret);
232
288
  this.jwtExpiresIn = config.jwtExpiresIn ?? DEFAULT_EXPIRES_IN;
233
289
  this.domain = config.domain;
234
290
  this.chainId = config.chainId;
291
+ this.issuer = config.issuer;
292
+ this.audience = config.audience;
235
293
  this.nonceManager = new NonceManager(config.sessionStore);
236
294
  this.now = config.now ?? (() => /* @__PURE__ */ new Date());
237
295
  }
@@ -308,28 +366,58 @@ var AuthService = class {
308
366
  expiresAt
309
367
  };
310
368
  await this.sessionStore.createSession(session);
311
- const token = await new SignJWT({
369
+ let signer = new SignJWT({
312
370
  userAddress,
313
371
  chainId: this.chainId
314
- }).setProtectedHeader({ alg: "HS256" }).setJti(tokenId).setIssuedAt(Math.floor(issuedAt.getTime() / 1e3)).setExpirationTime(Math.floor(expiresAt.getTime() / 1e3)).sign(this.jwtSecret);
372
+ }).setProtectedHeader({ alg: "HS256" }).setJti(tokenId).setIssuedAt(Math.floor(issuedAt.getTime() / 1e3)).setExpirationTime(Math.floor(expiresAt.getTime() / 1e3));
373
+ if (this.issuer) signer = signer.setIssuer(this.issuer);
374
+ if (this.audience) signer = signer.setAudience(this.audience);
375
+ const token = await signer.sign(this.jwtSecret);
315
376
  return { token, userAddress, tokenId, expiresAt };
316
377
  }
317
378
  /** Revoke the session backing the given JWT (logout). */
318
379
  async logout(token) {
380
+ let payload;
319
381
  try {
320
- const { payload } = await jwtVerify(token, this.jwtSecret, {
321
- clockTolerance: 60
382
+ const result = await jwtVerify(token, this.jwtSecret, {
383
+ clockTolerance: 60,
322
384
  // allow logout right after expiry
385
+ ...this.issuer ? { issuer: this.issuer } : {},
386
+ ...this.audience ? { audience: this.audience } : {}
323
387
  });
324
- if (payload.jti) {
325
- await this.sessionStore.revokeSession(payload.jti);
326
- }
388
+ payload = result.payload;
327
389
  } catch (err) {
328
- const msg = err instanceof Error ? err.message : String(err);
329
- if (!msg.includes("not found") && !msg.includes("expired")) {
330
- console.error("[PAFI] AuthService logout: session store error", err);
390
+ if (err instanceof joseErrors.JWTExpired) {
391
+ const decoded = decodeExpiredJwtJti(token);
392
+ if (decoded.jti) {
393
+ try {
394
+ await this.sessionStore.revokeSession(decoded.jti);
395
+ } catch (storeErr) {
396
+ this.logSessionStoreError(storeErr);
397
+ }
398
+ }
399
+ return;
331
400
  }
401
+ if (err instanceof joseErrors.JWSSignatureVerificationFailed || err instanceof joseErrors.JWSInvalid || err instanceof joseErrors.JWTInvalid) {
402
+ throw new AuthError("TOKEN_INVALID", "JWT verification failed");
403
+ }
404
+ throw new AuthError(
405
+ "TOKEN_INVALID",
406
+ `JWT verification failed: ${err instanceof Error ? err.message : String(err)}`
407
+ );
332
408
  }
409
+ if (payload.jti) {
410
+ try {
411
+ await this.sessionStore.revokeSession(payload.jti);
412
+ } catch (storeErr) {
413
+ this.logSessionStoreError(storeErr);
414
+ }
415
+ }
416
+ }
417
+ logSessionStoreError(err) {
418
+ const msg = err instanceof Error ? err.message : String(err);
419
+ if (msg.includes("not found")) return;
420
+ console.error("[PAFI] AuthService logout: session store error", err);
333
421
  }
334
422
  /**
335
423
  * Verify a JWT and return the authenticated user context. Throws an
@@ -339,7 +427,10 @@ var AuthService = class {
339
427
  async verifyToken(token) {
340
428
  let payload;
341
429
  try {
342
- const result = await jwtVerify(token, this.jwtSecret);
430
+ const result = await jwtVerify(token, this.jwtSecret, {
431
+ ...this.issuer ? { issuer: this.issuer } : {},
432
+ ...this.audience ? { audience: this.audience } : {}
433
+ });
343
434
  payload = result.payload;
344
435
  } catch (err) {
345
436
  if (err instanceof joseErrors.JWTExpired) {
@@ -405,6 +496,70 @@ async function authenticateRequest(authHeader, authService) {
405
496
  return authService.verifyToken(token);
406
497
  }
407
498
 
499
+ // src/auth/rateLimiter.ts
500
+ var DEFAULT_LIMITS = {
501
+ auth_nonce: { max: 30, windowMs: 6e4 },
502
+ // 30 nonces/min ≈ 1 per 2s
503
+ auth_login: { max: 5, windowMs: 6e4 }
504
+ // 5 logins/min
505
+ };
506
+ var MemoryRateLimiter = class {
507
+ buckets = /* @__PURE__ */ new Map();
508
+ limits;
509
+ now;
510
+ constructor(config = {}) {
511
+ if (process.env.NODE_ENV === "production" && !process.env.PAFI_ALLOW_MEMORY_RATE_LIMITER_IN_PROD) {
512
+ console.warn(
513
+ "[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."
514
+ );
515
+ }
516
+ this.limits = {
517
+ ...DEFAULT_LIMITS,
518
+ ...config.limits ?? {}
519
+ };
520
+ this.now = config.now ?? (() => Date.now());
521
+ }
522
+ async consume(key, action) {
523
+ const limit = this.limits[action];
524
+ if (!limit) return { allowed: true };
525
+ const bucketKey = `${action}:${key}`;
526
+ const now = this.now();
527
+ const bucket = this.buckets.get(bucketKey);
528
+ if (!bucket || now - bucket.windowStartedAt >= limit.windowMs) {
529
+ this.buckets.set(bucketKey, { count: 1, windowStartedAt: now });
530
+ return { allowed: true };
531
+ }
532
+ if (bucket.count < limit.max) {
533
+ bucket.count += 1;
534
+ return { allowed: true };
535
+ }
536
+ const retryAfterMs = Math.max(
537
+ 0,
538
+ bucket.windowStartedAt + limit.windowMs - now
539
+ );
540
+ return { allowed: false, retryAfterMs };
541
+ }
542
+ /**
543
+ * Test helper — clear all buckets. Not part of `IRateLimiter`; only
544
+ * exposed on the in-memory impl for unit tests.
545
+ */
546
+ reset() {
547
+ this.buckets.clear();
548
+ }
549
+ };
550
+ var NoopRateLimiter = class {
551
+ warned = false;
552
+ async consume() {
553
+ if (!this.warned && process.env.NODE_ENV === "production") {
554
+ console.warn(
555
+ "[PAFI] NoopRateLimiter active \u2014 `/auth/nonce` and `/auth/login` are NOT throttled. Wire a `MemoryRateLimiter` (dev) or Redis-backed impl (prod) via `IssuerApiHandlersConfig.rateLimiter`."
556
+ );
557
+ this.warned = true;
558
+ }
559
+ return { allowed: true };
560
+ }
561
+ };
562
+
408
563
  // src/relay/types.ts
409
564
  var RelayError = class extends PafiSdkError {
410
565
  httpStatus = "unprocessable";
@@ -761,6 +916,7 @@ var PointIndexer = class {
761
916
  confirmations;
762
917
  batchSize;
763
918
  pollIntervalMs;
919
+ onTickError;
764
920
  running = false;
765
921
  timer;
766
922
  constructor(config) {
@@ -776,6 +932,7 @@ var PointIndexer = class {
776
932
  this.confirmations = BigInt(config.confirmations ?? DEFAULT_CONFIRMATIONS);
777
933
  this.batchSize = BigInt(config.batchSize ?? Number(DEFAULT_BATCH_SIZE));
778
934
  this.pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
935
+ if (config.onTickError) this.onTickError = config.onTickError;
779
936
  }
780
937
  // -------------------------------------------------------------------------
781
938
  // Lifecycle
@@ -784,7 +941,7 @@ var PointIndexer = class {
784
941
  start() {
785
942
  if (this.running) return;
786
943
  this.running = true;
787
- void this.tick();
944
+ this.tick().catch((err) => this.handleTickError(err));
788
945
  }
789
946
  /** Stop polling. Safe to call multiple times. */
790
947
  stop() {
@@ -816,13 +973,27 @@ var PointIndexer = class {
816
973
  }
817
974
  await this.processBlockRange(from, safeHead);
818
975
  } catch (err) {
819
- console.error("[PAFI] PointIndexer tick error:", err);
976
+ this.handleTickError(err);
820
977
  }
821
978
  this.scheduleNext();
822
979
  }
980
+ handleTickError(err) {
981
+ if (this.onTickError) {
982
+ try {
983
+ this.onTickError(err);
984
+ } catch {
985
+ console.error("[PAFI] PointIndexer onTickError threw:", err);
986
+ }
987
+ } else {
988
+ console.error("[PAFI] PointIndexer tick error:", err);
989
+ }
990
+ }
823
991
  scheduleNext() {
824
992
  if (!this.running) return;
825
- this.timer = setTimeout(() => void this.tick(), this.pollIntervalMs);
993
+ this.timer = setTimeout(
994
+ () => this.tick().catch((err) => this.handleTickError(err)),
995
+ this.pollIntervalMs
996
+ );
826
997
  }
827
998
  // -------------------------------------------------------------------------
828
999
  // Block scanning
@@ -942,6 +1113,7 @@ var BurnIndexer = class {
942
1113
  confirmations;
943
1114
  batchSize;
944
1115
  pollIntervalMs;
1116
+ onTickError;
945
1117
  matchLockId;
946
1118
  running = false;
947
1119
  timer;
@@ -960,6 +1132,7 @@ var BurnIndexer = class {
960
1132
  );
961
1133
  this.batchSize = BigInt(config.batchSize ?? Number(DEFAULT_BATCH_SIZE2));
962
1134
  this.pollIntervalMs = config.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS2;
1135
+ if (config.onTickError) this.onTickError = config.onTickError;
963
1136
  if (!config.matchLockId) {
964
1137
  throw new Error(
965
1138
  "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."
@@ -970,7 +1143,7 @@ var BurnIndexer = class {
970
1143
  start() {
971
1144
  if (this.running) return;
972
1145
  this.running = true;
973
- void this.tick();
1146
+ this.tick().catch((err) => this.handleTickError(err));
974
1147
  }
975
1148
  stop() {
976
1149
  this.running = false;
@@ -996,13 +1169,27 @@ var BurnIndexer = class {
996
1169
  }
997
1170
  await this.processBlockRange(from, safeHead);
998
1171
  } catch (err) {
999
- console.error("[PAFI] BurnIndexer tick error:", err);
1172
+ this.handleTickError(err);
1000
1173
  }
1001
1174
  this.scheduleNext();
1002
1175
  }
1176
+ handleTickError(err) {
1177
+ if (this.onTickError) {
1178
+ try {
1179
+ this.onTickError(err);
1180
+ } catch {
1181
+ console.error("[PAFI] BurnIndexer onTickError threw:", err);
1182
+ }
1183
+ } else {
1184
+ console.error("[PAFI] BurnIndexer tick error:", err);
1185
+ }
1186
+ }
1003
1187
  scheduleNext() {
1004
1188
  if (!this.running) return;
1005
- this.timer = setTimeout(() => void this.tick(), this.pollIntervalMs);
1189
+ this.timer = setTimeout(
1190
+ () => this.tick().catch((err) => this.handleTickError(err)),
1191
+ this.pollIntervalMs
1192
+ );
1006
1193
  }
1007
1194
  /**
1008
1195
  * Scan `[from, to]` inclusive for burn events. Callers can drive this
@@ -1100,10 +1287,13 @@ var IssuerApiHandlers = class {
1100
1287
  pafiWebUrl;
1101
1288
  feeManager;
1102
1289
  poolsProvider;
1290
+ redemption;
1291
+ rateLimiter;
1103
1292
  constructor(config) {
1104
1293
  this.authService = config.authService;
1105
1294
  this.ledger = config.ledger;
1106
1295
  this.provider = config.provider;
1296
+ this.rateLimiter = config.rateLimiter ?? new NoopRateLimiter();
1107
1297
  const raw = config.pointTokenAddresses && config.pointTokenAddresses.length > 0 ? config.pointTokenAddresses : config.pointTokenAddress ? [config.pointTokenAddress] : [];
1108
1298
  if (raw.length === 0) {
1109
1299
  throw new Error(
@@ -1117,17 +1307,64 @@ var IssuerApiHandlers = class {
1117
1307
  if (config.pafiWebUrl) this.pafiWebUrl = config.pafiWebUrl;
1118
1308
  if (config.feeManager) this.feeManager = config.feeManager;
1119
1309
  if (config.poolsProvider) this.poolsProvider = config.poolsProvider;
1310
+ if (config.redemption) this.redemption = config.redemption;
1120
1311
  }
1121
1312
  // =========================================================================
1122
1313
  // Public handlers (no auth required)
1123
1314
  // =========================================================================
1124
- /** `GET /auth/nonce` */
1125
- async handleGetNonce() {
1315
+ /**
1316
+ * `GET /auth/nonce`
1317
+ *
1318
+ * @param rateLimitKey Caller-side rate-limit key (typically client IP).
1319
+ * The HTTP layer (controller/middleware) extracts
1320
+ * this from the request and passes it through.
1321
+ * When omitted, no rate limit applies — production
1322
+ * callers SHOULD always pass a key.
1323
+ */
1324
+ async handleGetNonce(rateLimitKey) {
1325
+ if (rateLimitKey) {
1326
+ const result = await this.rateLimiter.consume(
1327
+ rateLimitKey,
1328
+ "auth_nonce"
1329
+ );
1330
+ if (!result.allowed) {
1331
+ throw new ValidationError(
1332
+ "RATE_LIMIT_EXCEEDED",
1333
+ "handleGetNonce: too many requests",
1334
+ {
1335
+ retryAfterMs: result.retryAfterMs ?? 0,
1336
+ action: "auth_nonce"
1337
+ }
1338
+ );
1339
+ }
1340
+ }
1126
1341
  const nonce = await this.authService.getNonce();
1127
1342
  return { nonce };
1128
1343
  }
1129
- /** `POST /auth/login` */
1130
- async handleLogin(body) {
1344
+ /**
1345
+ * `POST /auth/login`
1346
+ *
1347
+ * @param body Login message + signature.
1348
+ * @param rateLimitKey Caller-side rate-limit key (typically client IP
1349
+ * or `body.userAddress` if known). See `handleGetNonce`.
1350
+ */
1351
+ async handleLogin(body, rateLimitKey) {
1352
+ if (rateLimitKey) {
1353
+ const result2 = await this.rateLimiter.consume(
1354
+ rateLimitKey,
1355
+ "auth_login"
1356
+ );
1357
+ if (!result2.allowed) {
1358
+ throw new ValidationError(
1359
+ "RATE_LIMIT_EXCEEDED",
1360
+ "handleLogin: too many requests",
1361
+ {
1362
+ retryAfterMs: result2.retryAfterMs ?? 0,
1363
+ action: "auth_login"
1364
+ }
1365
+ );
1366
+ }
1367
+ }
1131
1368
  if (!body || typeof body.message !== "string" || body.message.length === 0 || typeof body.signature !== "string" || body.signature.length <= 2) {
1132
1369
  throw new ValidationError(
1133
1370
  "INVALID_LOGIN_BODY",
@@ -1270,6 +1507,74 @@ var IssuerApiHandlers = class {
1270
1507
  // Note: legacy `handleClaim` (sync sponsored-claim returning calls[]) was
1271
1508
  // removed in 0.5.43 — callers should use `PTClaimHandler` directly or
1272
1509
  // wire `IssuerApiAdapter.claim()` which composes the full flow.
1510
+ /**
1511
+ * `GET /redemption/preview?pointToken=<addr>`
1512
+ *
1513
+ * Returns the headroom currently available to `userAddress` under the
1514
+ * configured RedemptionPolicy. Pure read — does not record anything.
1515
+ * Use this for UI to render "X PT redeemable now / next available at …".
1516
+ */
1517
+ async handleRedemptionPreview(userAddress, request) {
1518
+ if (!this.redemption) {
1519
+ throw new ConfigurationError(
1520
+ "REDEMPTION_NOT_CONFIGURED",
1521
+ "handleRedemptionPreview: redemption is not configured on this issuer"
1522
+ );
1523
+ }
1524
+ const tokenAddress = request.pointTokenAddress ? this.requireSupportedToken(getAddress5(request.pointTokenAddress), "handleRedemptionPreview") : void 0;
1525
+ const preview = await this.redemption.preview(
1526
+ getAddress5(userAddress),
1527
+ tokenAddress
1528
+ );
1529
+ return preview;
1530
+ }
1531
+ /**
1532
+ * `POST /redemption/evaluate`
1533
+ *
1534
+ * Pre-flight check before the issuer signs a BurnRequest. Returns
1535
+ * { allowed, denial?, preview }. Caller (the burn-orchestrator) MUST
1536
+ * re-check on the actual initiate path — evaluate is read-only and a
1537
+ * caller could race two requests under the same headroom. The intended
1538
+ * write path is: evaluate → sign BurnRequest → reserve pending credit
1539
+ * → call `service.redemption.recordSuccessfulInitiate()`.
1540
+ */
1541
+ async handleRedemptionEvaluate(userAddress, request) {
1542
+ if (!this.redemption) {
1543
+ throw new ConfigurationError(
1544
+ "REDEMPTION_NOT_CONFIGURED",
1545
+ "handleRedemptionEvaluate: redemption is not configured on this issuer"
1546
+ );
1547
+ }
1548
+ if (request.amountPt <= 0n) {
1549
+ throw new ValidationError(
1550
+ "INVALID_AMOUNT",
1551
+ "handleRedemptionEvaluate: amountPt must be positive",
1552
+ { amountPt: request.amountPt.toString() }
1553
+ );
1554
+ }
1555
+ const tokenAddress = request.pointTokenAddress ? this.requireSupportedToken(getAddress5(request.pointTokenAddress), "handleRedemptionEvaluate") : void 0;
1556
+ const decision = await this.redemption.evaluate(
1557
+ getAddress5(userAddress),
1558
+ request.amountPt,
1559
+ tokenAddress
1560
+ );
1561
+ const response = {
1562
+ allowed: decision.allowed,
1563
+ preview: decision.preview
1564
+ };
1565
+ if (decision.denial) response.denial = decision.denial;
1566
+ return response;
1567
+ }
1568
+ requireSupportedToken(pointToken, handler) {
1569
+ if (!this.supportedTokens.has(pointToken)) {
1570
+ throw new ValidationError(
1571
+ "UNSUPPORTED_POINT_TOKEN",
1572
+ `${handler}: unsupported pointToken ${pointToken}`,
1573
+ { requested: pointToken }
1574
+ );
1575
+ }
1576
+ return pointToken;
1577
+ }
1273
1578
  };
1274
1579
 
1275
1580
  // src/api/handlers/ptRedeemHandler.ts
@@ -1285,9 +1590,13 @@ var DEFAULT_SIG_DEADLINE_SEC = 15 * 60;
1285
1590
  var PTRedeemError = class extends PafiSdkError {
1286
1591
  httpStatus = "unprocessable";
1287
1592
  code;
1288
- constructor(code, message) {
1593
+ policyDenialCode;
1594
+ constructor(code, message, options) {
1289
1595
  super(message);
1290
1596
  this.code = code;
1597
+ if (options?.policyDenialCode) {
1598
+ this.policyDenialCode = options.policyDenialCode;
1599
+ }
1291
1600
  }
1292
1601
  };
1293
1602
  var PTRedeemHandler = class {
@@ -1303,6 +1612,7 @@ var PTRedeemHandler = class {
1303
1612
  redeemLockDurationMs;
1304
1613
  signatureDeadlineSeconds;
1305
1614
  now;
1615
+ redemptionService;
1306
1616
  /**
1307
1617
  * Per-user in-flight nonce guard (single-process only).
1308
1618
  *
@@ -1345,6 +1655,9 @@ var PTRedeemHandler = class {
1345
1655
  this.redeemLockDurationMs = config.redeemLockDurationMs ?? DEFAULT_REDEEM_LOCK_MS;
1346
1656
  this.signatureDeadlineSeconds = config.signatureDeadlineSeconds ?? DEFAULT_SIG_DEADLINE_SEC;
1347
1657
  this.now = config.now ?? (() => Date.now());
1658
+ if (config.redemptionService) {
1659
+ this.redemptionService = config.redemptionService;
1660
+ }
1348
1661
  }
1349
1662
  async handle(request) {
1350
1663
  if (getAddress6(request.authenticatedAddress) !== getAddress6(request.userAddress)) {
@@ -1356,6 +1669,21 @@ var PTRedeemHandler = class {
1356
1669
  if (request.amount <= 0n) {
1357
1670
  throw new PTRedeemError("INVALID_AMOUNT", "redeem amount must be positive");
1358
1671
  }
1672
+ if (this.redemptionService) {
1673
+ const decision = await this.redemptionService.evaluate(
1674
+ request.userAddress,
1675
+ request.amount,
1676
+ this.pointTokenAddress
1677
+ );
1678
+ if (!decision.allowed) {
1679
+ const denial = decision.denial;
1680
+ throw new PTRedeemError(
1681
+ "REDEMPTION_POLICY_DENIED",
1682
+ `redemption denied: ${denial.message}`,
1683
+ { policyDenialCode: denial.code }
1684
+ );
1685
+ }
1686
+ }
1359
1687
  let burnNonce;
1360
1688
  try {
1361
1689
  burnNonce = await this.provider.readContract({
@@ -1509,6 +1837,15 @@ var PTRedeemHandler = class {
1509
1837
  netCreditAmount: request.amount
1510
1838
  };
1511
1839
  }
1840
+ if (this.redemptionService) {
1841
+ await this.redemptionService.recordSuccessfulInitiate({
1842
+ user: request.userAddress,
1843
+ amountPt: request.amount,
1844
+ pointTokenAddress: this.pointTokenAddress,
1845
+ reservationId: sponsoredLockId
1846
+ }).catch(() => {
1847
+ });
1848
+ }
1512
1849
  return {
1513
1850
  lockId: sponsoredLockId,
1514
1851
  userOp: sponsoredUserOp,
@@ -2403,31 +2740,6 @@ async function handleDelegateSubmit(params) {
2403
2740
  return { userOpHash: result.userOpHash };
2404
2741
  }
2405
2742
 
2406
- // src/api/errorMapper.ts
2407
- function createSdkErrorMapper(factories) {
2408
- return (err) => {
2409
- if (!(err instanceof PafiSdkError)) {
2410
- throw err;
2411
- }
2412
- const body = {
2413
- code: err.code,
2414
- message: err.message,
2415
- details: err.details,
2416
- safeToRetry: err.safeToRetry
2417
- };
2418
- switch (err.httpStatus) {
2419
- case "not_found":
2420
- throw factories.notFound(body);
2421
- case "forbidden":
2422
- throw factories.forbidden(body);
2423
- case "unprocessable":
2424
- throw factories.unprocessable(body);
2425
- case "service_unavailable":
2426
- throw factories.serviceUnavailable(body);
2427
- }
2428
- };
2429
- }
2430
-
2431
2743
  // src/api/issuerApiAdapter.ts
2432
2744
  import { randomUUID } from "crypto";
2433
2745
  import { getAddress as getAddress10 } from "viem";
@@ -3267,6 +3579,7 @@ var BalanceAggregator = class {
3267
3579
  };
3268
3580
 
3269
3581
  // src/pafi-backend/client.ts
3582
+ import { getPafiServiceUrls } from "@pafi-dev/core";
3270
3583
  function serializeBigInt(_key, value) {
3271
3584
  return typeof value === "bigint" ? value.toString(10) : value;
3272
3585
  }
@@ -3275,10 +3588,13 @@ function sleep(ms) {
3275
3588
  }
3276
3589
  var PafiBackendClient = class {
3277
3590
  config;
3591
+ baseUrl;
3278
3592
  constructor(config) {
3279
- if (!config.url) throw new Error("PafiBackendClient: url is required");
3593
+ if (!config.chainId) throw new Error("PafiBackendClient: chainId is required");
3280
3594
  if (!config.issuerId) throw new Error("PafiBackendClient: issuerId is required");
3595
+ if (!config.apiKey) throw new Error("PafiBackendClient: apiKey is required");
3281
3596
  this.config = config;
3597
+ this.baseUrl = getPafiServiceUrls(config.chainId).sponsorRelayer;
3282
3598
  }
3283
3599
  async requestSponsorship(request) {
3284
3600
  const maxAttempts = this.config.retry?.maxAttempts ?? 1;
@@ -3313,7 +3629,7 @@ var PafiBackendClient = class {
3313
3629
  */
3314
3630
  async getUserOpReceipt(userOpHash) {
3315
3631
  const fetchFn = this.config.fetchImpl ?? fetch;
3316
- const url = `${this.config.url}/bundler/receipt`;
3632
+ const url = `${this.baseUrl}/bundler/receipt`;
3317
3633
  let response;
3318
3634
  try {
3319
3635
  response = await fetchFn(url, {
@@ -3352,7 +3668,7 @@ var PafiBackendClient = class {
3352
3668
  }
3353
3669
  async relayUserOperation(request) {
3354
3670
  const fetchFn = this.config.fetchImpl ?? fetch;
3355
- const url = `${this.config.url}/bundler/relay`;
3671
+ const url = `${this.baseUrl}/bundler/relay`;
3356
3672
  let response;
3357
3673
  try {
3358
3674
  response = await fetchFn(url, {
@@ -3386,7 +3702,7 @@ var PafiBackendClient = class {
3386
3702
  }
3387
3703
  async _doRequest(request) {
3388
3704
  const fetchFn = this.config.fetchImpl ?? fetch;
3389
- const url = `${this.config.url}/paymaster/sponsor`;
3705
+ const url = `${this.baseUrl}/paymaster/sponsor`;
3390
3706
  const body = JSON.stringify(request, serializeBigInt);
3391
3707
  let response;
3392
3708
  try {
@@ -3442,6 +3758,314 @@ var PafiBackendClient = class {
3442
3758
  // src/config.ts
3443
3759
  import { getAddress as getAddress11 } from "viem";
3444
3760
  import { getContractAddresses as getContractAddresses7 } from "@pafi-dev/core";
3761
+
3762
+ // src/redemption/evaluator.ts
3763
+ var SECONDS_PER_DAY = 24 * 60 * 60;
3764
+ function evaluateRedemption(input) {
3765
+ const { policy, history, amountPt, nowUnixSec, policySource } = input;
3766
+ const dailyRemaining = policy.dailyLimitPt > history.redeemedLast24hPt ? policy.dailyLimitPt - history.redeemedLast24hPt : 0n;
3767
+ const cooldownUntilUnixSec = history.lastRedeemedAtUnixSec !== null ? history.lastRedeemedAtUnixSec + policy.cooldownSec : null;
3768
+ const inCooldown = cooldownUntilUnixSec !== null && cooldownUntilUnixSec > nowUnixSec;
3769
+ const activeBlackout = findActiveBlackout(policy.blackoutWindows, nowUnixSec);
3770
+ const nextBlackoutEnd = nextBlackoutEndAfter(
3771
+ policy.blackoutWindows,
3772
+ nowUnixSec
3773
+ );
3774
+ let availableAmountPt = 0n;
3775
+ if (!inCooldown && !activeBlackout) {
3776
+ const headroom = dailyRemaining < policy.perTxMaxPt ? dailyRemaining : policy.perTxMaxPt;
3777
+ availableAmountPt = headroom >= policy.perTxMinPt ? headroom : 0n;
3778
+ }
3779
+ const preview = {
3780
+ availableAmountPt,
3781
+ dailyRemainingPt: dailyRemaining,
3782
+ cooldownUntilUnixSec: inCooldown ? cooldownUntilUnixSec : null,
3783
+ nextBlackoutEndsAtUnixSec: activeBlackout ? activeBlackout.endUnixSec : nextBlackoutEnd,
3784
+ perTxMinPt: policy.perTxMinPt,
3785
+ perTxMaxPt: policy.perTxMaxPt,
3786
+ policyVersion: policy.version,
3787
+ policySource
3788
+ };
3789
+ if (amountPt <= 0n) {
3790
+ return { allowed: false, preview, denial: rejectAmountBelowMin(policy) };
3791
+ }
3792
+ const denial = firstDenial({
3793
+ amountPt,
3794
+ policy,
3795
+ dailyRemaining,
3796
+ inCooldown,
3797
+ cooldownUntilUnixSec,
3798
+ activeBlackout
3799
+ });
3800
+ if (denial) return { allowed: false, denial, preview };
3801
+ return { allowed: true, preview };
3802
+ }
3803
+ function firstDenial(args) {
3804
+ const { amountPt, policy, dailyRemaining, inCooldown, cooldownUntilUnixSec, activeBlackout } = args;
3805
+ if (activeBlackout) {
3806
+ return {
3807
+ code: "BLACKOUT_WINDOW",
3808
+ message: `Redemption is blocked until ${new Date(
3809
+ activeBlackout.endUnixSec * 1e3
3810
+ ).toISOString()}${activeBlackout.reason ? ` (${activeBlackout.reason})` : ""}`
3811
+ };
3812
+ }
3813
+ if (inCooldown && cooldownUntilUnixSec !== null) {
3814
+ return {
3815
+ code: "COOLDOWN_ACTIVE",
3816
+ message: `Cooldown active until ${new Date(
3817
+ cooldownUntilUnixSec * 1e3
3818
+ ).toISOString()}`
3819
+ };
3820
+ }
3821
+ if (amountPt < policy.perTxMinPt) {
3822
+ return {
3823
+ code: "AMOUNT_BELOW_MIN",
3824
+ message: `amount ${amountPt} below per-tx minimum ${policy.perTxMinPt}`
3825
+ };
3826
+ }
3827
+ if (amountPt > policy.perTxMaxPt) {
3828
+ return {
3829
+ code: "AMOUNT_ABOVE_MAX",
3830
+ message: `amount ${amountPt} above per-tx maximum ${policy.perTxMaxPt}`
3831
+ };
3832
+ }
3833
+ if (amountPt > dailyRemaining) {
3834
+ return {
3835
+ code: "DAILY_LIMIT_EXCEEDED",
3836
+ message: `amount ${amountPt} exceeds daily remaining ${dailyRemaining}`
3837
+ };
3838
+ }
3839
+ return null;
3840
+ }
3841
+ function rejectAmountBelowMin(policy) {
3842
+ return {
3843
+ code: "AMOUNT_BELOW_MIN",
3844
+ message: `amount must be >= ${policy.perTxMinPt}`
3845
+ };
3846
+ }
3847
+ function findActiveBlackout(windows, nowUnixSec) {
3848
+ for (const w of windows) {
3849
+ if (w.startUnixSec <= nowUnixSec && nowUnixSec < w.endUnixSec) return w;
3850
+ }
3851
+ return null;
3852
+ }
3853
+ function nextBlackoutEndAfter(windows, nowUnixSec) {
3854
+ let earliest = null;
3855
+ for (const w of windows) {
3856
+ if (w.endUnixSec > nowUnixSec) {
3857
+ if (earliest === null || w.endUnixSec < earliest) earliest = w.endUnixSec;
3858
+ }
3859
+ }
3860
+ return earliest;
3861
+ }
3862
+ var REDEMPTION_HISTORY_WINDOW_SEC = SECONDS_PER_DAY;
3863
+
3864
+ // src/redemption/settlementClient.ts
3865
+ import {
3866
+ getPafiServiceUrls as getPafiServiceUrls2
3867
+ } from "@pafi-dev/core";
3868
+ var DEFAULT_TIMEOUT_MS = 1e3;
3869
+ var SettlementClient = class {
3870
+ config;
3871
+ constructor(config) {
3872
+ if (!config.chainId) throw new Error("SettlementClient: chainId is required");
3873
+ if (!config.issuerId) throw new Error("SettlementClient: issuerId is required");
3874
+ if (!config.apiKey) throw new Error("SettlementClient: apiKey is required");
3875
+ this.config = {
3876
+ baseUrl: getPafiServiceUrls2(config.chainId).issuerApi.replace(/\/+$/, ""),
3877
+ issuerId: config.issuerId,
3878
+ apiKey: config.apiKey,
3879
+ fetchTimeoutMs: config.fetchTimeoutMs ?? DEFAULT_TIMEOUT_MS,
3880
+ fetchImpl: config.fetchImpl
3881
+ };
3882
+ }
3883
+ async fetchPolicy() {
3884
+ const fetchFn = this.config.fetchImpl ?? fetch;
3885
+ const url = `${this.config.baseUrl}/issuers/${encodeURIComponent(this.config.issuerId)}/redemption-policy`;
3886
+ const controller = new AbortController();
3887
+ const timer = setTimeout(
3888
+ () => controller.abort(),
3889
+ this.config.fetchTimeoutMs
3890
+ );
3891
+ let response;
3892
+ try {
3893
+ response = await fetchFn(url, {
3894
+ method: "GET",
3895
+ headers: {
3896
+ "Content-Type": "application/json",
3897
+ Authorization: `Bearer ${this.config.apiKey}`,
3898
+ "X-Issuer-Id": this.config.issuerId
3899
+ },
3900
+ signal: controller.signal
3901
+ });
3902
+ } catch (err) {
3903
+ const isAbort = err instanceof Error && (err.name === "AbortError" || /aborted|timeout/i.test(err.message ?? ""));
3904
+ return { ok: false, reason: isAbort ? "TIMEOUT" : "NETWORK" };
3905
+ } finally {
3906
+ clearTimeout(timer);
3907
+ }
3908
+ if (response.status === 404) {
3909
+ return { ok: false, reason: "NOT_FOUND", status: 404 };
3910
+ }
3911
+ if (response.status === 401 || response.status === 403) {
3912
+ return { ok: false, reason: "UNAUTHORIZED", status: response.status };
3913
+ }
3914
+ if (!response.ok) {
3915
+ return { ok: false, reason: "SERVER_ERROR", status: response.status };
3916
+ }
3917
+ let raw;
3918
+ try {
3919
+ raw = await response.json();
3920
+ } catch {
3921
+ return { ok: false, reason: "INVALID_RESPONSE", status: response.status };
3922
+ }
3923
+ const parsed = parsePolicyDto(raw);
3924
+ if (!parsed) {
3925
+ return { ok: false, reason: "INVALID_RESPONSE", status: response.status };
3926
+ }
3927
+ return { ok: true, policy: parsed };
3928
+ }
3929
+ };
3930
+ function parsePolicyDto(raw) {
3931
+ if (!raw || typeof raw !== "object") return null;
3932
+ const dto = raw;
3933
+ 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)) {
3934
+ return null;
3935
+ }
3936
+ try {
3937
+ return {
3938
+ issuerId: dto.issuerId,
3939
+ dailyLimitPt: BigInt(dto.dailyLimitPt),
3940
+ cooldownSec: dto.cooldownSec,
3941
+ perTxMinPt: BigInt(dto.perTxMinPt),
3942
+ perTxMaxPt: BigInt(dto.perTxMaxPt),
3943
+ blackoutWindows: dto.blackoutWindows.map(normalizeBlackout).filter((b) => b !== null),
3944
+ version: dto.version
3945
+ };
3946
+ } catch {
3947
+ return null;
3948
+ }
3949
+ }
3950
+ function normalizeBlackout(raw) {
3951
+ if (!raw || typeof raw !== "object") return null;
3952
+ const win = raw;
3953
+ if (typeof win.startUnixSec !== "number" || typeof win.endUnixSec !== "number" || win.startUnixSec >= win.endUnixSec) {
3954
+ return null;
3955
+ }
3956
+ return {
3957
+ startUnixSec: win.startUnixSec,
3958
+ endUnixSec: win.endUnixSec,
3959
+ reason: typeof win.reason === "string" ? win.reason : void 0
3960
+ };
3961
+ }
3962
+
3963
+ // src/redemption/defaults.ts
3964
+ var PT_DECIMALS = 10n ** 18n;
3965
+ var DEFAULT_REDEMPTION_POLICY = {
3966
+ issuerId: "default",
3967
+ dailyLimitPt: 1000n * PT_DECIMALS,
3968
+ cooldownSec: 60,
3969
+ perTxMinPt: 1n * PT_DECIMALS,
3970
+ perTxMaxPt: 500n * PT_DECIMALS,
3971
+ blackoutWindows: [],
3972
+ version: "default-v1"
3973
+ };
3974
+ function defaultPolicyFor(issuerId) {
3975
+ return { ...DEFAULT_REDEMPTION_POLICY, issuerId };
3976
+ }
3977
+
3978
+ // src/redemption/policyProvider.ts
3979
+ var DEFAULT_CACHE_TTL_MS3 = 5 * 60 * 1e3;
3980
+ var PolicyProvider = class {
3981
+ client;
3982
+ issuerId;
3983
+ cacheTtlMs;
3984
+ now;
3985
+ cache = null;
3986
+ inflight = null;
3987
+ constructor(config) {
3988
+ this.client = new SettlementClient(config);
3989
+ this.issuerId = config.issuerId;
3990
+ this.cacheTtlMs = config.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS3;
3991
+ this.now = config.now ?? (() => Date.now());
3992
+ }
3993
+ async getPolicy() {
3994
+ const fresh = this.readCache();
3995
+ if (fresh) return { policy: fresh, source: "cache" };
3996
+ if (this.inflight) return this.inflight;
3997
+ this.inflight = this.fetchAndStore().finally(() => {
3998
+ this.inflight = null;
3999
+ });
4000
+ return this.inflight;
4001
+ }
4002
+ /** Drop cached policy. Next getPolicy() will refetch. */
4003
+ invalidate() {
4004
+ this.cache = null;
4005
+ }
4006
+ readCache() {
4007
+ if (!this.cache) return null;
4008
+ if (this.cache.expiresAtMs <= this.now()) {
4009
+ this.cache = null;
4010
+ return null;
4011
+ }
4012
+ return this.cache.policy;
4013
+ }
4014
+ async fetchAndStore() {
4015
+ const result = await this.client.fetchPolicy();
4016
+ if (result.ok) {
4017
+ this.cache = {
4018
+ policy: result.policy,
4019
+ expiresAtMs: this.now() + this.cacheTtlMs
4020
+ };
4021
+ return { policy: result.policy, source: "settlement" };
4022
+ }
4023
+ return { policy: defaultPolicyFor(this.issuerId), source: "default" };
4024
+ }
4025
+ };
4026
+
4027
+ // src/redemption/service.ts
4028
+ var RedemptionService = class {
4029
+ policyProvider;
4030
+ historyStore;
4031
+ nowUnixSec;
4032
+ constructor(config) {
4033
+ this.policyProvider = config.policyProvider instanceof PolicyProvider ? config.policyProvider : new PolicyProvider(config.policyProvider);
4034
+ this.historyStore = config.historyStore;
4035
+ this.nowUnixSec = config.nowUnixSec ?? (() => Math.floor(Date.now() / 1e3));
4036
+ }
4037
+ async preview(user, pointTokenAddress) {
4038
+ const decision = await this.evaluate(user, 0n, pointTokenAddress);
4039
+ return decision.preview;
4040
+ }
4041
+ async evaluate(user, amountPt, pointTokenAddress) {
4042
+ const { policy, source } = await this.policyProvider.getPolicy();
4043
+ const now = this.nowUnixSec();
4044
+ const [redeemedLast24hPt, lastRedeemedAtUnixSec] = await Promise.all([
4045
+ this.historyStore.sumRedeemedSince(
4046
+ user,
4047
+ now - REDEMPTION_HISTORY_WINDOW_SEC,
4048
+ pointTokenAddress
4049
+ ),
4050
+ this.historyStore.getLastRedeemedAtUnixSec(user, pointTokenAddress)
4051
+ ]);
4052
+ return evaluateRedemption({
4053
+ policy,
4054
+ policySource: source,
4055
+ history: { redeemedLast24hPt, lastRedeemedAtUnixSec },
4056
+ amountPt,
4057
+ nowUnixSec: now
4058
+ });
4059
+ }
4060
+ async recordSuccessfulInitiate(entry) {
4061
+ await this.historyStore.recordRedemption({
4062
+ ...entry,
4063
+ unixSec: this.nowUnixSec()
4064
+ });
4065
+ }
4066
+ };
4067
+
4068
+ // src/config.ts
3445
4069
  function createIssuerService(config) {
3446
4070
  if (!config.provider) {
3447
4071
  throw new Error("createIssuerService: provider is required");
@@ -3520,6 +4144,25 @@ function createIssuerService(config) {
3520
4144
  pafiHook: chainAddresses.pafiHook,
3521
4145
  ...config.contracts
3522
4146
  };
4147
+ let redemption;
4148
+ if (config.redemption) {
4149
+ const policyConfig = {
4150
+ chainId: config.chainId,
4151
+ issuerId: config.redemption.issuerId,
4152
+ apiKey: config.redemption.apiKey
4153
+ };
4154
+ if (config.redemption.fetchImpl) policyConfig.fetchImpl = config.redemption.fetchImpl;
4155
+ if (config.redemption.fetchTimeoutMs !== void 0) {
4156
+ policyConfig.fetchTimeoutMs = config.redemption.fetchTimeoutMs;
4157
+ }
4158
+ if (config.redemption.cacheTtlMs !== void 0) {
4159
+ policyConfig.cacheTtlMs = config.redemption.cacheTtlMs;
4160
+ }
4161
+ redemption = new RedemptionService({
4162
+ policyProvider: new PolicyProvider(policyConfig),
4163
+ historyStore: config.redemption.historyStore
4164
+ });
4165
+ }
3523
4166
  const handlersConfig = {
3524
4167
  authService,
3525
4168
  ledger,
@@ -3530,6 +4173,7 @@ function createIssuerService(config) {
3530
4173
  };
3531
4174
  if (feeManager) handlersConfig.feeManager = feeManager;
3532
4175
  if (config.poolsProvider) handlersConfig.poolsProvider = config.poolsProvider;
4176
+ if (redemption) handlersConfig.redemption = redemption;
3533
4177
  const handlers = new IssuerApiHandlers(handlersConfig);
3534
4178
  if (config.indexer?.autoStart) {
3535
4179
  for (const idx of indexers.values()) {
@@ -3545,7 +4189,8 @@ function createIssuerService(config) {
3545
4189
  fee: feeManager,
3546
4190
  indexers,
3547
4191
  indexer: firstIndexer,
3548
- api: handlers
4192
+ api: handlers,
4193
+ redemption
3549
4194
  };
3550
4195
  }
3551
4196
 
@@ -3709,8 +4354,44 @@ var IssuerStateValidator = class _IssuerStateValidator {
3709
4354
  }
3710
4355
  };
3711
4356
 
4357
+ // src/redemption/memoryHistoryStore.ts
4358
+ var MemoryRedemptionHistoryStore = class {
4359
+ entries = [];
4360
+ async sumRedeemedSince(user, sinceUnixSec, pointTokenAddress) {
4361
+ const userKey = user.toLowerCase();
4362
+ const tokenKey = pointTokenAddress?.toLowerCase() ?? null;
4363
+ let total = 0n;
4364
+ for (const e of this.entries) {
4365
+ if (e.user !== userKey) continue;
4366
+ if (e.unixSec < sinceUnixSec) continue;
4367
+ if (tokenKey !== null && e.pointTokenAddress !== tokenKey) continue;
4368
+ total += e.amountPt;
4369
+ }
4370
+ return total;
4371
+ }
4372
+ async getLastRedeemedAtUnixSec(user, pointTokenAddress) {
4373
+ const userKey = user.toLowerCase();
4374
+ const tokenKey = pointTokenAddress?.toLowerCase() ?? null;
4375
+ let latest = null;
4376
+ for (const e of this.entries) {
4377
+ if (e.user !== userKey) continue;
4378
+ if (tokenKey !== null && e.pointTokenAddress !== tokenKey) continue;
4379
+ if (latest === null || e.unixSec > latest) latest = e.unixSec;
4380
+ }
4381
+ return latest;
4382
+ }
4383
+ async recordRedemption(entry) {
4384
+ this.entries.push({
4385
+ user: entry.user.toLowerCase(),
4386
+ amountPt: entry.amountPt,
4387
+ pointTokenAddress: entry.pointTokenAddress?.toLowerCase() ?? null,
4388
+ unixSec: entry.unixSec
4389
+ });
4390
+ }
4391
+ };
4392
+
3712
4393
  // src/index.ts
3713
- var PAFI_ISSUER_SDK_VERSION = true ? "0.7.8" : "dev";
4394
+ var PAFI_ISSUER_SDK_VERSION = true ? "0.9.0" : "dev";
3714
4395
  export {
3715
4396
  AdapterMisconfiguredError,
3716
4397
  AuthError,
@@ -3720,6 +4401,7 @@ export {
3720
4401
  BundlerRejectedError,
3721
4402
  BurnIndexer,
3722
4403
  ConfigurationError,
4404
+ DEFAULT_REDEMPTION_POLICY,
3723
4405
  DefaultPolicyEngine,
3724
4406
  FeeManager,
3725
4407
  InMemoryCursorStore,
@@ -3729,8 +4411,11 @@ export {
3729
4411
  IssuerStateValidator,
3730
4412
  LockNotFoundError,
3731
4413
  MemoryPendingUserOpStore,
4414
+ MemoryRateLimiter,
4415
+ MemoryRedemptionHistoryStore,
3732
4416
  MemorySessionStore,
3733
4417
  NonceManager,
4418
+ NoopRateLimiter,
3734
4419
  PAFI_ISSUER_SDK_VERSION,
3735
4420
  PAFI_SUBGRAPH_URL,
3736
4421
  PTClaimError,
@@ -3745,21 +4430,34 @@ export {
3745
4430
  PerpDepositError,
3746
4431
  PerpDepositHandler,
3747
4432
  PointIndexer,
4433
+ PolicyProvider,
4434
+ REDEMPTION_HISTORY_WINDOW_SEC,
4435
+ RedemptionService,
3748
4436
  RelayError,
3749
4437
  RelayService,
4438
+ SDK_ERROR_HTTP_STATUS_CODE,
4439
+ SettlementClient,
3750
4440
  ValidationError,
3751
4441
  authenticateRequest,
4442
+ buildErrorEnvelope,
4443
+ buildSdkErrorBody,
3752
4444
  createIssuerService,
3753
4445
  createNativePtQuoter,
3754
4446
  createSdkErrorMapper,
3755
4447
  createSubgraphNativeUsdtQuoter,
3756
4448
  createSubgraphPoolsProvider,
4449
+ defaultErrorTypeForStatus,
4450
+ defaultPolicyFor,
4451
+ evaluateRedemption,
3757
4452
  handleClaimStatus,
3758
4453
  handleDelegateSubmit,
3759
4454
  handleMobilePrepare,
3760
4455
  handleMobileSubmit,
3761
4456
  handleRedeemStatus,
3762
4457
  mergePaymasterFields,
4458
+ payloadFromGenericError,
4459
+ payloadFromHttpException,
4460
+ payloadFromPafiSdkError,
3763
4461
  prepareMobileUserOp,
3764
4462
  relayUserOp,
3765
4463
  requestPaymaster,