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