@pafi-dev/issuer-postgres 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ var __decorateClass = (decorators, target, key, kind) => {
10
10
  };
11
11
 
12
12
  // src/postgresPointLedger.ts
13
+ import { MoreThan } from "typeorm";
13
14
  import { getAddress } from "viem";
14
15
 
15
16
  // src/entities/locked-mint.entity.ts
@@ -79,7 +80,22 @@ __decorateClass([
79
80
  ], LockedMintEntity.prototype, "userOpHash", 2);
80
81
  LockedMintEntity = __decorateClass([
81
82
  Entity({ name: "locked_mint_requests" }),
82
- Index(["userAddress", "status"])
83
+ Index("IDX_locked_mint_user_token_status_expires", [
84
+ "userAddress",
85
+ "tokenAddress",
86
+ "status",
87
+ "expiresAt"
88
+ ]),
89
+ Index("IDX_locked_mint_user_token_amount_status", [
90
+ "userAddress",
91
+ "tokenAddress",
92
+ "amount",
93
+ "status"
94
+ ]),
95
+ Index("IDX_locked_mint_pending_expires", ["expiresAt"], {
96
+ where: `"status" = 'PENDING'`
97
+ }),
98
+ Index("IDX_locked_mint_user_op_hash", ["userOpHash"])
83
99
  ], LockedMintEntity);
84
100
 
85
101
  // src/entities/pending-credit.entity.ts
@@ -244,11 +260,22 @@ __decorateClass([
244
260
  ], LedgerJournalEntity.prototype, "createdAt", 2);
245
261
  LedgerJournalEntity = __decorateClass([
246
262
  Entity4({ name: "ledger_journal" }),
247
- Index3(["userAddress", "createdAt"])
263
+ Index3(["userAddress", "createdAt"]),
264
+ Index3(
265
+ "UQ_ledger_journal_user_token_tx_reason",
266
+ ["userAddress", "tokenAddress", "txHash", "reason"],
267
+ {
268
+ unique: true,
269
+ where: '"tx_hash" IS NOT NULL'
270
+ }
271
+ )
248
272
  ], LedgerJournalEntity);
249
273
 
250
274
  // src/postgresPointLedger.ts
251
275
  var RETRIABLE_PG_CODES = /* @__PURE__ */ new Set(["40P01", "40001"]);
276
+ var UNIQUE_VIOLATION = "23505";
277
+ var JOURNAL_IDEMPOTENCY_CONSTRAINT = "UQ_ledger_journal_user_token_tx_reason";
278
+ var LEGACY_JOURNAL_IDEMPOTENCY_CONSTRAINT = "UQ_ledger_journal_user_tx_reason";
252
279
  function isRetriablePgError(err) {
253
280
  const e = err;
254
281
  if (!e) return false;
@@ -258,6 +285,18 @@ function isRetriablePgError(err) {
258
285
  }
259
286
  return false;
260
287
  }
288
+ function isJournalIdempotencyViolation(err) {
289
+ const e = err;
290
+ if (!e) return false;
291
+ const code = e.code ?? e.driverError?.code;
292
+ if (code !== UNIQUE_VIOLATION) return false;
293
+ const constraint = e.constraint ?? e.driverError?.constraint;
294
+ if (constraint === JOURNAL_IDEMPOTENCY_CONSTRAINT || constraint === LEGACY_JOURNAL_IDEMPOTENCY_CONSTRAINT) {
295
+ return true;
296
+ }
297
+ const message = e.message ?? e.driverError?.message ?? "";
298
+ return message.includes(JOURNAL_IDEMPOTENCY_CONSTRAINT) || message.includes(LEGACY_JOURNAL_IDEMPOTENCY_CONSTRAINT);
299
+ }
261
300
  async function withDeadlockRetry(dataSource, fn, maxAttempts = 3) {
262
301
  let attempt = 0;
263
302
  let delayMs = 25;
@@ -329,6 +368,40 @@ var PostgresPointLedger = class {
329
368
  }
330
369
  return swept;
331
370
  }
371
+ /**
372
+ * Audit PACI5-20 — symmetric counterpart of `markExpiredLocks` for
373
+ * the redeem/burn (PendingCredit) side. Marks all expired PENDING
374
+ * pending_credits as EXPIRED so abandoned reservations can no
375
+ * longer hijack later burns' attribution in `findPendingCreditLockId`.
376
+ *
377
+ * The README has always promised this sweep; the pre-fix code only
378
+ * shipped the mint-side `markExpiredLocks`, leaving credits to
379
+ * accumulate forever (storage bloat) and stay matchable past their
380
+ * deadline (attribution corruption). Wire it into the BurnIndexer
381
+ * tick the same way `markExpiredLocks` is wired into the
382
+ * MintIndexer tick — typically every 1-5 minutes.
383
+ *
384
+ * Defense-in-depth: `findPendingCreditLockId` also filters
385
+ * `expires_at > now()` so a missed-tick window can't reintroduce
386
+ * the hijack.
387
+ *
388
+ * @example
389
+ * ```ts
390
+ * @Interval(60_000)
391
+ * async sweep() {
392
+ * await this.ledger.markExpiredLocks();
393
+ * await this.ledger.markExpiredCredits();
394
+ * }
395
+ * ```
396
+ */
397
+ async markExpiredCredits() {
398
+ const result = await this.dataSource.getRepository(PendingCreditEntity).createQueryBuilder().update().set({ status: "EXPIRED" }).where("status = :pending", { pending: "PENDING" }).andWhere("expires_at <= :now", { now: /* @__PURE__ */ new Date() }).execute();
399
+ const swept = result.affected ?? 0;
400
+ if (swept > 0) {
401
+ this.logger?.debug?.(`markExpiredCredits: swept ${swept} pending credits`);
402
+ }
403
+ return swept;
404
+ }
332
405
  async getLockedRequests(userAddress, tokenAddress) {
333
406
  const { user, token } = normalize(userAddress, tokenAddress);
334
407
  const rows = await this.dataSource.getRepository(LockedMintEntity).find({
@@ -453,43 +526,72 @@ var PostgresPointLedger = class {
453
526
  throw new Error("deductBalance: amount must be positive");
454
527
  }
455
528
  const { user, token } = normalize(userAddress, tokenAddress);
456
- await withDeadlockRetry(this.dataSource, async (tx) => {
457
- const balance = await tx.getRepository(UserBalanceEntity).createQueryBuilder("balance").setLock("pessimistic_write").where("balance.user_address = :user", { user }).andWhere("balance.token_address = :token", { token }).getOne();
458
- if (!balance || balance.balance < amount) {
459
- throw new Error(
460
- `Cannot deduct ${amount} from balance ${balance?.balance ?? 0n}`
529
+ try {
530
+ await withDeadlockRetry(this.dataSource, async (tx) => {
531
+ const balance = await tx.getRepository(UserBalanceEntity).createQueryBuilder("balance").setLock("pessimistic_write").where("balance.user_address = :user", { user }).andWhere("balance.token_address = :token", { token }).getOne();
532
+ const already = await tx.getRepository(LedgerJournalEntity).findOne({
533
+ where: {
534
+ userAddress: user,
535
+ tokenAddress: token,
536
+ txHash,
537
+ reason: "MINT_CONFIRMED"
538
+ }
539
+ });
540
+ if (already) {
541
+ this.logger?.debug?.(
542
+ `deductBalance: idempotent skip tx=${txHash} user=${user} token=${token}`
543
+ );
544
+ return;
545
+ }
546
+ if (!balance || balance.balance < amount) {
547
+ throw new Error(
548
+ `Cannot deduct ${amount} from balance ${balance?.balance ?? 0n}`
549
+ );
550
+ }
551
+ await tx.getRepository(UserBalanceEntity).update(
552
+ { userAddress: user, tokenAddress: token },
553
+ { balance: balance.balance - amount }
461
554
  );
462
- }
463
- await tx.getRepository(UserBalanceEntity).update(
464
- { userAddress: user, tokenAddress: token },
465
- { balance: balance.balance - amount }
466
- );
467
- await tx.getRepository(LedgerJournalEntity).insert({
468
- userAddress: user,
469
- tokenAddress: token,
470
- delta: -amount,
471
- reason: "MINT_CONFIRMED",
472
- txHash
473
- });
474
- const match = await tx.getRepository(LockedMintEntity).findOne({
475
- where: {
555
+ await tx.getRepository(LedgerJournalEntity).insert({
476
556
  userAddress: user,
477
557
  tokenAddress: token,
478
- amount,
479
- status: "PENDING"
480
- },
481
- order: { createdAt: "ASC" }
558
+ delta: -amount,
559
+ reason: "MINT_CONFIRMED",
560
+ txHash
561
+ });
562
+ const match = await tx.getRepository(LockedMintEntity).findOne({
563
+ where: {
564
+ userAddress: user,
565
+ tokenAddress: token,
566
+ amount,
567
+ status: "PENDING"
568
+ },
569
+ order: { createdAt: "ASC" }
570
+ });
571
+ if (match) {
572
+ await tx.getRepository(LockedMintEntity).update({ id: match.id }, { status: "MINTED", txHash });
573
+ }
482
574
  });
483
- if (match) {
484
- await tx.getRepository(LockedMintEntity).update({ id: match.id }, { status: "MINTED", txHash });
575
+ } catch (err) {
576
+ if (isJournalIdempotencyViolation(err)) {
577
+ this.logger?.debug?.(
578
+ `deductBalance: idempotent (concurrent race) tx=${txHash} user=${user}`
579
+ );
580
+ return;
485
581
  }
486
- });
582
+ throw err;
583
+ }
487
584
  this.logger?.log?.(`deduct ${user}[${token}] -${amount} tx=${txHash}`);
488
585
  }
489
586
  async updateMintStatus(lockId, status, txHash) {
490
587
  const update = { status };
491
588
  if (txHash) update.txHash = txHash;
492
- await this.dataSource.getRepository(LockedMintEntity).update({ id: lockId }, update);
589
+ const result = await this.dataSource.getRepository(LockedMintEntity).createQueryBuilder().update().set(update).where("id = :id", { id: lockId }).andWhere("status = :pending", { pending: "PENDING" }).execute();
590
+ if ((result.affected ?? 0) === 0) {
591
+ this.logger?.debug?.(
592
+ `updateMintStatus: lock ${lockId} not in PENDING (terminal state), no-op`
593
+ );
594
+ }
493
595
  }
494
596
  async bindMintUserOpHash(lockId, userOpHash) {
495
597
  await this.dataSource.getRepository(LockedMintEntity).update({ id: lockId }, { userOpHash });
@@ -521,7 +623,7 @@ var PostgresPointLedger = class {
521
623
  return row.id;
522
624
  }
523
625
  async resolveCreditByBurnTx(lockId, txHash) {
524
- await withDeadlockRetry(this.dataSource, async (tx) => {
626
+ const run = async () => withDeadlockRetry(this.dataSource, async (tx) => {
525
627
  const credit = await tx.getRepository(PendingCreditEntity).createQueryBuilder("credit").setLock("pessimistic_write").where("credit.id = :id", { id: lockId }).getOne();
526
628
  if (!credit) {
527
629
  throw new Error(
@@ -574,13 +676,46 @@ var PostgresPointLedger = class {
574
676
  { status: "RESOLVED", txHash, resolvedAt: /* @__PURE__ */ new Date() }
575
677
  );
576
678
  });
679
+ try {
680
+ await run();
681
+ } catch (err) {
682
+ if (isJournalIdempotencyViolation(err)) {
683
+ this.logger?.debug?.(
684
+ `resolveCreditByBurnTx: concurrent race on tx=${txHash} lock=${lockId}, replaying via sibling defense`
685
+ );
686
+ await run();
687
+ } else {
688
+ throw err;
689
+ }
690
+ }
577
691
  this.logger?.log?.(`resolve pending credit ${lockId} tx=${txHash}`);
578
692
  }
579
693
  /**
580
694
  * Used by `BurnIndexer.matchLockId` to resolve an on-chain burn
581
- * event back to a pending credit row. Returns the oldest matching
582
- * `(user, token, amount, status: PENDING)` lockId, or undefined
583
- * when no match exists (unsolicited burn — indexer skips).
695
+ * event back to a pending credit row. Returns the **newest**
696
+ * matching `(user, token, amount, status: PENDING, expires_at > now)`
697
+ * lockId, or undefined when no match exists (unsolicited burn —
698
+ * indexer skips).
699
+ *
700
+ * Audit PACI5-20 — two filters narrowed since the pre-fix shipped:
701
+ *
702
+ * 1. `expires_at > now()` — abandoned PENDING reservations past
703
+ * their deadline no longer hijack a fresh burn's attribution.
704
+ * This is defense-in-depth in case `markExpiredCredits` hasn't
705
+ * run recently (missed-tick window).
706
+ *
707
+ * 2. `ORDER BY createdAt DESC` (was ASC) — when multiple PENDING
708
+ * reservations match the same (user, token, amount) within
709
+ * the validity window, prefer the most recent one. Matches
710
+ * user mental model: "I just submitted /redeem, my recent
711
+ * reservation should win" rather than resurrecting a stale
712
+ * one. With the sweep + filter the typical steady state has
713
+ * at most one matching row, so this only affects edge cases
714
+ * (very fast re-submit, or concurrent flows).
715
+ *
716
+ * Combined with the partial UNIQUE index on `(txHash, status =
717
+ * RESOLVED)` from PACI5-7, the attribution can no longer drift
718
+ * across sessions even under adversarial conditions.
584
719
  */
585
720
  async findPendingCreditLockId(userAddress, amount, tokenAddress) {
586
721
  const { user, token } = normalize(userAddress, tokenAddress);
@@ -589,9 +724,10 @@ var PostgresPointLedger = class {
589
724
  userAddress: user,
590
725
  tokenAddress: token,
591
726
  amount,
592
- status: "PENDING"
727
+ status: "PENDING",
728
+ expiresAt: MoreThan(/* @__PURE__ */ new Date())
593
729
  },
594
- order: { createdAt: "ASC" }
730
+ order: { createdAt: "DESC" }
595
731
  });
596
732
  return row?.id;
597
733
  }
@@ -930,10 +1066,103 @@ var CreateRedemptionHistory1746230400001 = class {
930
1066
  }
931
1067
  };
932
1068
 
1069
+ // src/migrations/1747500000000-AddJournalIdempotencyIndex.ts
1070
+ var AddJournalIdempotencyIndex1747500000000 = class {
1071
+ name = "AddJournalIdempotencyIndex1747500000000";
1072
+ async up(queryRunner) {
1073
+ await queryRunner.query(`
1074
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_ledger_journal_user_tx_reason"
1075
+ ON "ledger_journal" ("user_address", "tx_hash", "reason")
1076
+ WHERE "tx_hash" IS NOT NULL
1077
+ `);
1078
+ }
1079
+ async down(queryRunner) {
1080
+ await queryRunner.query(
1081
+ `DROP INDEX IF EXISTS "UQ_ledger_journal_user_tx_reason"`
1082
+ );
1083
+ }
1084
+ };
1085
+
1086
+ // src/migrations/1747600000000-AddLockedMintCompositeIndexes.ts
1087
+ var AddLockedMintCompositeIndexes1747600000000 = class {
1088
+ name = "AddLockedMintCompositeIndexes1747600000000";
1089
+ /**
1090
+ * CONCURRENTLY index DDL cannot run inside a transaction. Tell
1091
+ * TypeORM to issue these statements directly.
1092
+ */
1093
+ transaction = false;
1094
+ async up(queryRunner) {
1095
+ await queryRunner.query(`
1096
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
1097
+ "IDX_locked_mint_user_token_status_expires"
1098
+ ON "locked_mint_requests"
1099
+ ("user_address", "token_address", "status", "expires_at")
1100
+ `);
1101
+ await queryRunner.query(`
1102
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
1103
+ "IDX_locked_mint_user_token_amount_status"
1104
+ ON "locked_mint_requests"
1105
+ ("user_address", "token_address", "amount", "status")
1106
+ `);
1107
+ await queryRunner.query(`
1108
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS
1109
+ "IDX_locked_mint_pending_expires"
1110
+ ON "locked_mint_requests" ("expires_at")
1111
+ WHERE "status" = 'PENDING'
1112
+ `);
1113
+ await queryRunner.query(
1114
+ `DROP INDEX CONCURRENTLY IF EXISTS "IDX_locked_mint_user_status"`
1115
+ );
1116
+ }
1117
+ async down(queryRunner) {
1118
+ await queryRunner.query(`
1119
+ CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_locked_mint_user_status"
1120
+ ON "locked_mint_requests" ("user_address", "token_address", "status")
1121
+ `);
1122
+ await queryRunner.query(
1123
+ `DROP INDEX CONCURRENTLY IF EXISTS "IDX_locked_mint_pending_expires"`
1124
+ );
1125
+ await queryRunner.query(
1126
+ `DROP INDEX CONCURRENTLY IF EXISTS "IDX_locked_mint_user_token_amount_status"`
1127
+ );
1128
+ await queryRunner.query(
1129
+ `DROP INDEX CONCURRENTLY IF EXISTS "IDX_locked_mint_user_token_status_expires"`
1130
+ );
1131
+ }
1132
+ };
1133
+
1134
+ // src/migrations/1747700000000-FixIdempotencyAddTokenAddress.ts
1135
+ var FixIdempotencyAddTokenAddress1747700000000 = class {
1136
+ name = "FixIdempotencyAddTokenAddress1747700000000";
1137
+ async up(queryRunner) {
1138
+ await queryRunner.query(
1139
+ `DROP INDEX IF EXISTS "UQ_ledger_journal_user_tx_reason"`
1140
+ );
1141
+ await queryRunner.query(`
1142
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_ledger_journal_user_token_tx_reason"
1143
+ ON "ledger_journal" ("user_address", "token_address", "tx_hash", "reason")
1144
+ WHERE "tx_hash" IS NOT NULL
1145
+ `);
1146
+ }
1147
+ async down(queryRunner) {
1148
+ await queryRunner.query(
1149
+ `DROP INDEX IF EXISTS "UQ_ledger_journal_user_token_tx_reason"`
1150
+ );
1151
+ await queryRunner.query(`
1152
+ CREATE UNIQUE INDEX IF NOT EXISTS "UQ_ledger_journal_user_tx_reason"
1153
+ ON "ledger_journal" ("user_address", "tx_hash", "reason")
1154
+ WHERE "tx_hash" IS NOT NULL
1155
+ `);
1156
+ }
1157
+ };
1158
+
933
1159
  // src/migrations/index.ts
934
1160
  var PAFI_MIGRATIONS = [
935
1161
  InitialSchema1700000000000,
936
- CreateRedemptionHistory1746230400001
1162
+ CreateRedemptionHistory1746230400001,
1163
+ AddJournalIdempotencyIndex1747500000000,
1164
+ AddLockedMintCompositeIndexes1747600000000,
1165
+ FixIdempotencyAddTokenAddress1747700000000
937
1166
  ];
938
1167
  export {
939
1168
  CreateRedemptionHistory1746230400001,