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