@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/chunk-R4FYJZ2N.js +1 -0
- package/dist/chunk-R4FYJZ2N.js.map +1 -0
- package/dist/chunk-U3WMORJG.js +230 -0
- package/dist/chunk-U3WMORJG.js.map +1 -0
- package/dist/http/index.cjs +169 -0
- package/dist/http/index.cjs.map +1 -0
- package/dist/http/index.d.cts +112 -0
- package/dist/http/index.d.ts +112 -0
- package/dist/http/index.js +14 -0
- package/dist/http/index.js.map +1 -0
- package/dist/index.cjs +927 -66
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +502 -25
- package/dist/index.d.ts +502 -25
- package/dist/index.js +772 -74
- package/dist/index.js.map +1 -1
- package/dist/nestjs/index.cjs +314 -0
- package/dist/nestjs/index.cjs.map +1 -0
- package/dist/nestjs/index.d.cts +54 -0
- package/dist/nestjs/index.d.ts +54 -0
- package/dist/nestjs/index.js +124 -0
- package/dist/nestjs/index.js.map +1 -0
- package/package.json +29 -2
package/dist/index.js
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
import "./chunk-R4FYJZ2N.js";
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
90
|
-
"[PAFI] MemorySessionStore
|
|
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
|
-
|
|
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
|
-
|
|
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))
|
|
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
|
|
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
|
-
|
|
325
|
-
await this.sessionStore.revokeSession(payload.jti);
|
|
326
|
-
}
|
|
388
|
+
payload = result.payload;
|
|
327
389
|
} catch (err) {
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
/**
|
|
1125
|
-
|
|
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
|
-
/**
|
|
1130
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|