@pafi-dev/issuer 0.21.0 → 0.22.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.d.cts CHANGED
@@ -983,6 +983,56 @@ declare class InMemoryCursorStore implements IIndexerCursorStore {
983
983
  load(): Promise<bigint | undefined>;
984
984
  save(blockNumber: bigint): Promise<void>;
985
985
  }
986
+ /**
987
+ * Lock handle returned by a successful `ISingletonLock.acquire()`.
988
+ *
989
+ * The holder is the leader for the keyed indexer; non-holders MUST NOT
990
+ * call `indexer.start()` (else they race against the leader's polling
991
+ * loop and last-writer-wins cursor save corrupts replay state).
992
+ *
993
+ * `release()` is called by the leader on graceful shutdown. If the
994
+ * leader crashes without calling release, the lock implementation
995
+ * MUST drop the lock automatically (e.g. Postgres advisory locks
996
+ * auto-release on connection close).
997
+ */
998
+ interface SingletonLockHandle {
999
+ release(): Promise<void>;
1000
+ }
1001
+ /**
1002
+ * Leader-election primitive for indexer singletons.
1003
+ *
1004
+ * **Why this exists (audit finding H-04):** the `BurnIndexer` and
1005
+ * `PointIndexer` classes are stateful polling loops with a cursor
1006
+ * stored in an external store. Running them on multiple replicas
1007
+ * simultaneously causes:
1008
+ *
1009
+ * 1. Double-credit — both replicas see the same Transfer event
1010
+ * and call `ledger.resolveCreditByBurnTx` twice.
1011
+ * 2. Cursor rewind — last-writer-wins `save()` causes the newer
1012
+ * cursor to be overwritten by a lagging replica's older value,
1013
+ * causing the next poll to replay events.
1014
+ * 3. Skipped blocks — chunked range reads can leave gaps when two
1015
+ * replicas race-advance the cursor.
1016
+ *
1017
+ * **Adoption:** pass `singletonLock` into `IssuerServiceConfig.indexer`
1018
+ * (or wire it directly in your provider). The factory will only call
1019
+ * `indexer.start()` on the indexers it successfully acquires a lock
1020
+ * for; the rest stay idle and take over instantly on lock release.
1021
+ *
1022
+ * **Implementations:** `makePostgresSingletonLock(dataSource)` (recommended
1023
+ * — uses `pg_try_advisory_lock`, auto-releases on connection close).
1024
+ * You can also bring your own: Redis SETNX with TTL, etcd lease, etc.
1025
+ */
1026
+ interface ISingletonLock {
1027
+ /**
1028
+ * Attempt to acquire the lock for `key`. Returns a handle on success
1029
+ * (caller is the leader), or `null` if another holder owns it.
1030
+ *
1031
+ * Implementations MUST be non-blocking — if the lock is held, return
1032
+ * null immediately. The factory polls / retries at a higher layer.
1033
+ */
1034
+ acquire(key: string): Promise<SingletonLockHandle | null>;
1035
+ }
986
1036
 
987
1037
  interface PointIndexerConfig {
988
1038
  provider: PublicClient;
@@ -1190,7 +1240,11 @@ interface BurnIndexerConfig {
1190
1240
  */
1191
1241
  declare class BurnIndexer {
1192
1242
  private readonly provider;
1193
- private readonly pointTokenAddress;
1243
+ /**
1244
+ * The PointToken this indexer watches. Exposed so callers can key
1245
+ * leader-election locks / cursor stores by token (audit H-04 fix).
1246
+ */
1247
+ readonly pointTokenAddress: Address;
1194
1248
  private readonly ledger;
1195
1249
  private readonly cursorStore;
1196
1250
  private readonly startBlock;
@@ -1222,6 +1276,60 @@ declare class BurnIndexer {
1222
1276
  private finalize;
1223
1277
  }
1224
1278
 
1279
+ /**
1280
+ * Minimal duck-typed adapter so we don't drag a TypeORM / pg dep into
1281
+ * the SDK. The caller passes anything that can execute a parameterised
1282
+ * query and yield `{ got: boolean }` rows — TypeORM `DataSource.query`,
1283
+ * a raw `pg` client, knex's `raw`, kysely, etc.
1284
+ *
1285
+ * The helper requires `int8` (signed 64-bit) lock IDs. We derive them
1286
+ * from the lock `key` via a deterministic hash so different replicas
1287
+ * agree on the int regardless of casing / whitespace drift.
1288
+ */
1289
+ interface PostgresQueryRunner {
1290
+ query(sql: string, params?: unknown[]): Promise<unknown>;
1291
+ }
1292
+ /**
1293
+ * Build an `ISingletonLock` backed by PostgreSQL session-level advisory
1294
+ * locks (`pg_try_advisory_lock`).
1295
+ *
1296
+ * Why advisory locks (vs. row-level locks or a leases table):
1297
+ * 1. **Non-blocking** — `pg_try_advisory_lock` returns immediately;
1298
+ * perfect for the "try then idle" pattern the SDK uses on boot.
1299
+ * 2. **Auto-release on connection drop** — if the leader pod crashes,
1300
+ * its PG connection closes, and the lock is freed automatically.
1301
+ * No timeout tuning, no lease renewal loops, no zombie locks.
1302
+ * 3. **Zero schema** — no extra table, no migration, no contention
1303
+ * with application reads.
1304
+ *
1305
+ * **Caveat — long-lived connection required.** Session-level locks are
1306
+ * held only for the lifetime of the PG connection that called
1307
+ * `pg_try_advisory_lock`. PgBouncer in `transaction` pooling mode will
1308
+ * release the lock between statements; you MUST run in `session`
1309
+ * pooling or skip the bouncer for the indexer process. The advisory
1310
+ * `transaction` variant exists but doesn't fit the "hold while polling"
1311
+ * pattern — for that you'd need a periodic re-acquire, which we don't
1312
+ * implement here.
1313
+ *
1314
+ * @example
1315
+ * import { DataSource } from "typeorm";
1316
+ * import { makePostgresSingletonLock } from "@pafi-dev/issuer";
1317
+ *
1318
+ * const dataSource = new DataSource({ ... });
1319
+ * const lock = makePostgresSingletonLock({
1320
+ * query: (sql, params) => dataSource.query(sql, params),
1321
+ * });
1322
+ *
1323
+ * createIssuerService({
1324
+ * ...,
1325
+ * indexer: {
1326
+ * autoStart: true,
1327
+ * singletonLock: lock,
1328
+ * },
1329
+ * });
1330
+ */
1331
+ declare function makePostgresSingletonLock(runner: PostgresQueryRunner): ISingletonLock;
1332
+
1225
1333
  interface ApiConfigResponse {
1226
1334
  chainId: number;
1227
1335
  contracts: {
@@ -2890,8 +2998,27 @@ interface IssuerServiceConfig {
2890
2998
  /**
2891
2999
  * If `true`, the factory calls `indexer.start()` before returning.
2892
3000
  * Default: `false` — the caller decides when to begin polling.
3001
+ *
3002
+ * **SAFETY (H-04):** in a multi-replica deployment, ALWAYS pair
3003
+ * `autoStart: true` with `singletonLock` below. Without leader
3004
+ * election, every replica starts its own indexer fleet and races
3005
+ * against the others — see `ISingletonLock` docs for the
3006
+ * consequences.
2893
3007
  */
2894
3008
  autoStart?: boolean;
3009
+ /**
3010
+ * Leader-election primitive. When provided, the factory wraps
3011
+ * `indexer.start()` with a `singletonLock.acquire(key)` call and
3012
+ * only starts the indexer if it wins the lock. Non-leaders stay
3013
+ * idle and take over on the next acquire attempt (when the leader
3014
+ * pod's connection drops, the lock auto-releases).
3015
+ *
3016
+ * The lock key includes the indexer kind + the PointToken address,
3017
+ * so different tokens can be sharded across replicas if desired.
3018
+ *
3019
+ * Recommended: `makePostgresSingletonLock(dataSource)`.
3020
+ */
3021
+ singletonLock?: ISingletonLock;
2895
3022
  /**
2896
3023
  * Override the MintFeeWrapper address used by the indexer. When
2897
3024
  * omitted, the factory auto-resolves from
@@ -2937,6 +3064,13 @@ interface IssuerService {
2937
3064
  fee: FeeManager | undefined;
2938
3065
  /** All indexers keyed by PointToken address. */
2939
3066
  indexers: Map<Address, PointIndexer>;
3067
+ /**
3068
+ * Lock handles for the indexers this replica was elected leader for.
3069
+ * Empty when `autoStart` is false, or when no `singletonLock` was
3070
+ * provided. Call `release()` on each during graceful shutdown so
3071
+ * peers can take over without waiting for the connection to die.
3072
+ */
3073
+ indexerLeaderLocks: SingletonLockHandle[];
2940
3074
  /** Framework-agnostic HTTP handlers — wire into Express / Fastify / Hono. */
2941
3075
  api: IssuerApiHandlers;
2942
3076
  /**
@@ -2958,7 +3092,7 @@ interface IssuerService {
2958
3092
  *
2959
3093
  * Throws synchronously if any required field is missing.
2960
3094
  */
2961
- declare function createIssuerService(config: IssuerServiceConfig): IssuerService;
3095
+ declare function createIssuerService(config: IssuerServiceConfig): Promise<IssuerService>;
2962
3096
 
2963
3097
  /**
2964
3098
  * Adapter that absorbs every "framework-agnostic" endpoint body into a
@@ -3698,4 +3832,4 @@ declare class MemoryRedemptionHistoryStore implements IRedemptionHistoryStore {
3698
3832
 
3699
3833
  declare const PAFI_ISSUER_SDK_VERSION: string;
3700
3834
 
3701
- export { AdapterMisconfiguredError, type ApiConfigResponse, type ApiGasFeeResponse, type ApiLoginRequest, type ApiLoginResponse, type ApiNonceResponse, type ApiPoolsRequest, type ApiPoolsResponse, type ApiRedemptionEvaluateRequest, type ApiRedemptionEvaluateResponse, type ApiRedemptionPreviewRequest, type ApiRedemptionPreviewResponse, type ApiUserRequest, type ApiUserResponse, type AuthContext, AuthError, type AuthErrorCode, AuthService, type AuthServiceConfig, type BundlerEstimatorClient, BundlerNotConfiguredError, BundlerRejectedError, type BurnEvent, BurnIndexer, type BurnIndexerConfig, type BurnStatusParams, type BurnStatusResponse, type ClaimDto, type ConfigDto, ConfigurationError, DEFAULT_REDEMPTION_POLICY, type DecodedCallDto, DefaultPolicyEngine, type DefaultPolicyEngineOptions, type DelegatePrepareDto, type DelegateStatusDto, type EstimateGasFeeOptions, type EvaluateInput, FeeManager, type FeeManagerConfig, type FeeManagerMetrics, type FetchFailureReason, type FetchImpl, type FetchResult, type GasFeeDto, type GasFeeSource, type HandleDelegateSubmitParams, type HandleDelegateSubmitResult, type HandleMobilePrepareParams, type HandleMobilePrepareResult, type HandleMobileSubmitParams, type IIndexerCursorStore, type IPendingUserOpStore, type IPointLedger, type IPolicyEngine, type IRateLimiter, type IRedemptionHistoryStore, type ISessionStore, InMemoryCursorStore, IssuerApiAdapter, type IssuerApiAdapterConfig, IssuerApiHandlers, type IssuerApiHandlersConfig, type IssuerRegistryRecord, type IssuerService, type IssuerServiceConfig, IssuerStateError, IssuerStateValidator, LockNotFoundError, type LockedMintRequest, type LoginResult, MemoryPendingUserOpStore, MemoryRateLimiter, MemoryRedemptionHistoryStore, MemorySessionStore, type MemorySessionStoreOptions, type MintEvent, type MintStatusParams, type MintStatusResponse, type MintingStatus, type MobilePrepareDto, type MobileSubmitDto, type NativePtQuoterConfig, NonceManager, NoopRateLimiter, PAFI_ISSUER_SDK_VERSION, PTClaimError, PTClaimHandler, type PTClaimHandlerConfig, type PTClaimRequest, type PTClaimResponse, PTRedeemError, PTRedeemHandler, type PTRedeemHandlerConfig, type PTRedeemRequest, type PTRedeemResponse, PafiBackendClient, type PafiBackendConfig, PafiBackendError, type PafiBackendErrorCode, type PafiEstimatorClientConfig, PafiEstimatorHttpError, type PaymasterGasEstimates, type PendingCredit, type PendingUserOpEntry, PendingUserOpForbiddenError, PendingUserOpNotFoundError, type PerpDepositDto, PerpDepositError, PerpDepositHandler, type PerpDepositHandlerConfig, type PerpDepositRequest, type PerpDepositResponse, PointIndexer, type PointIndexerConfig, PointTokenDomainResolver, type PointTokenDomainResolverConfig, type PolicyDecision, type PolicyEvalRequest, PolicyProvider, type PolicyProviderConfig, type PoolsDto, type PoolsProvider, type PreValidateMintResult, type PrepareBurnParams, type PrepareMintParams, type PrepareMobileUserOpParams, type PrepareMobileUserOpResult, type PreparedUserOp, type PreviewBurnParams, type PreviewMintParams, REDEMPTION_HISTORY_WINDOW_SEC, type RateLimitAction, type RateLimiterConfig, type RedeemDto, type RedeemPrepareDto, RedemptionService, type RedemptionServiceConfig, RelayError, type RelayErrorCode, RelayService, type RelayUserOpParams, type RelayUserOpRequest, type RelayUserOpResponse, type RequestPaymasterParams, type ResolvedPolicy, type RetryConfig, type SdkErrorBody, type SdkErrorMapperFactories, type SdkErrorStatus, type SerializedUserOpTypedData, type Session, SettlementClient, type SettlementClientConfig, type SponsorshipRequest, type SponsorshipResponse, type SponsorshipTarget, type SponsorshipUserOp, type SubgraphNativeUsdtQuoterConfig, type SubgraphPoolsProviderConfig, type UserDto, type UserHistory, applyPaymasterGasEstimates, authenticateRequest, buildSdkErrorBody, createIssuerService, createNativePtQuoter, createPafiEstimatorClient, createSdkErrorMapper, createSubgraphNativeUsdtQuoter, createSubgraphPoolsProvider, defaultPolicyFor, evaluateRedemption, handleClaimStatus, handleDelegateSubmit, handleMobilePrepare, handleMobileSubmit, handleRedeemStatus, mergePaymasterFields, prepareMobileUserOp, relayUserOp, requestPaymaster, serializeEntryToJsonRpc, serializeUserOpTypedData };
3835
+ export { AdapterMisconfiguredError, type ApiConfigResponse, type ApiGasFeeResponse, type ApiLoginRequest, type ApiLoginResponse, type ApiNonceResponse, type ApiPoolsRequest, type ApiPoolsResponse, type ApiRedemptionEvaluateRequest, type ApiRedemptionEvaluateResponse, type ApiRedemptionPreviewRequest, type ApiRedemptionPreviewResponse, type ApiUserRequest, type ApiUserResponse, type AuthContext, AuthError, type AuthErrorCode, AuthService, type AuthServiceConfig, type BundlerEstimatorClient, BundlerNotConfiguredError, BundlerRejectedError, type BurnEvent, BurnIndexer, type BurnIndexerConfig, type BurnStatusParams, type BurnStatusResponse, type ClaimDto, type ConfigDto, ConfigurationError, DEFAULT_REDEMPTION_POLICY, type DecodedCallDto, DefaultPolicyEngine, type DefaultPolicyEngineOptions, type DelegatePrepareDto, type DelegateStatusDto, type EstimateGasFeeOptions, type EvaluateInput, FeeManager, type FeeManagerConfig, type FeeManagerMetrics, type FetchFailureReason, type FetchImpl, type FetchResult, type GasFeeDto, type GasFeeSource, type HandleDelegateSubmitParams, type HandleDelegateSubmitResult, type HandleMobilePrepareParams, type HandleMobilePrepareResult, type HandleMobileSubmitParams, type IIndexerCursorStore, type IPendingUserOpStore, type IPointLedger, type IPolicyEngine, type IRateLimiter, type IRedemptionHistoryStore, type ISessionStore, type ISingletonLock, InMemoryCursorStore, IssuerApiAdapter, type IssuerApiAdapterConfig, IssuerApiHandlers, type IssuerApiHandlersConfig, type IssuerRegistryRecord, type IssuerService, type IssuerServiceConfig, IssuerStateError, IssuerStateValidator, LockNotFoundError, type LockedMintRequest, type LoginResult, MemoryPendingUserOpStore, MemoryRateLimiter, MemoryRedemptionHistoryStore, MemorySessionStore, type MemorySessionStoreOptions, type MintEvent, type MintStatusParams, type MintStatusResponse, type MintingStatus, type MobilePrepareDto, type MobileSubmitDto, type NativePtQuoterConfig, NonceManager, NoopRateLimiter, PAFI_ISSUER_SDK_VERSION, PTClaimError, PTClaimHandler, type PTClaimHandlerConfig, type PTClaimRequest, type PTClaimResponse, PTRedeemError, PTRedeemHandler, type PTRedeemHandlerConfig, type PTRedeemRequest, type PTRedeemResponse, PafiBackendClient, type PafiBackendConfig, PafiBackendError, type PafiBackendErrorCode, type PafiEstimatorClientConfig, PafiEstimatorHttpError, type PaymasterGasEstimates, type PendingCredit, type PendingUserOpEntry, PendingUserOpForbiddenError, PendingUserOpNotFoundError, type PerpDepositDto, PerpDepositError, PerpDepositHandler, type PerpDepositHandlerConfig, type PerpDepositRequest, type PerpDepositResponse, PointIndexer, type PointIndexerConfig, PointTokenDomainResolver, type PointTokenDomainResolverConfig, type PolicyDecision, type PolicyEvalRequest, PolicyProvider, type PolicyProviderConfig, type PoolsDto, type PoolsProvider, type PostgresQueryRunner, type PreValidateMintResult, type PrepareBurnParams, type PrepareMintParams, type PrepareMobileUserOpParams, type PrepareMobileUserOpResult, type PreparedUserOp, type PreviewBurnParams, type PreviewMintParams, REDEMPTION_HISTORY_WINDOW_SEC, type RateLimitAction, type RateLimiterConfig, type RedeemDto, type RedeemPrepareDto, RedemptionService, type RedemptionServiceConfig, RelayError, type RelayErrorCode, RelayService, type RelayUserOpParams, type RelayUserOpRequest, type RelayUserOpResponse, type RequestPaymasterParams, type ResolvedPolicy, type RetryConfig, type SdkErrorBody, type SdkErrorMapperFactories, type SdkErrorStatus, type SerializedUserOpTypedData, type Session, SettlementClient, type SettlementClientConfig, type SingletonLockHandle, type SponsorshipRequest, type SponsorshipResponse, type SponsorshipTarget, type SponsorshipUserOp, type SubgraphNativeUsdtQuoterConfig, type SubgraphPoolsProviderConfig, type UserDto, type UserHistory, applyPaymasterGasEstimates, authenticateRequest, buildSdkErrorBody, createIssuerService, createNativePtQuoter, createPafiEstimatorClient, createSdkErrorMapper, createSubgraphNativeUsdtQuoter, createSubgraphPoolsProvider, defaultPolicyFor, evaluateRedemption, handleClaimStatus, handleDelegateSubmit, handleMobilePrepare, handleMobileSubmit, handleRedeemStatus, makePostgresSingletonLock, mergePaymasterFields, prepareMobileUserOp, relayUserOp, requestPaymaster, serializeEntryToJsonRpc, serializeUserOpTypedData };
package/dist/index.d.ts CHANGED
@@ -983,6 +983,56 @@ declare class InMemoryCursorStore implements IIndexerCursorStore {
983
983
  load(): Promise<bigint | undefined>;
984
984
  save(blockNumber: bigint): Promise<void>;
985
985
  }
986
+ /**
987
+ * Lock handle returned by a successful `ISingletonLock.acquire()`.
988
+ *
989
+ * The holder is the leader for the keyed indexer; non-holders MUST NOT
990
+ * call `indexer.start()` (else they race against the leader's polling
991
+ * loop and last-writer-wins cursor save corrupts replay state).
992
+ *
993
+ * `release()` is called by the leader on graceful shutdown. If the
994
+ * leader crashes without calling release, the lock implementation
995
+ * MUST drop the lock automatically (e.g. Postgres advisory locks
996
+ * auto-release on connection close).
997
+ */
998
+ interface SingletonLockHandle {
999
+ release(): Promise<void>;
1000
+ }
1001
+ /**
1002
+ * Leader-election primitive for indexer singletons.
1003
+ *
1004
+ * **Why this exists (audit finding H-04):** the `BurnIndexer` and
1005
+ * `PointIndexer` classes are stateful polling loops with a cursor
1006
+ * stored in an external store. Running them on multiple replicas
1007
+ * simultaneously causes:
1008
+ *
1009
+ * 1. Double-credit — both replicas see the same Transfer event
1010
+ * and call `ledger.resolveCreditByBurnTx` twice.
1011
+ * 2. Cursor rewind — last-writer-wins `save()` causes the newer
1012
+ * cursor to be overwritten by a lagging replica's older value,
1013
+ * causing the next poll to replay events.
1014
+ * 3. Skipped blocks — chunked range reads can leave gaps when two
1015
+ * replicas race-advance the cursor.
1016
+ *
1017
+ * **Adoption:** pass `singletonLock` into `IssuerServiceConfig.indexer`
1018
+ * (or wire it directly in your provider). The factory will only call
1019
+ * `indexer.start()` on the indexers it successfully acquires a lock
1020
+ * for; the rest stay idle and take over instantly on lock release.
1021
+ *
1022
+ * **Implementations:** `makePostgresSingletonLock(dataSource)` (recommended
1023
+ * — uses `pg_try_advisory_lock`, auto-releases on connection close).
1024
+ * You can also bring your own: Redis SETNX with TTL, etcd lease, etc.
1025
+ */
1026
+ interface ISingletonLock {
1027
+ /**
1028
+ * Attempt to acquire the lock for `key`. Returns a handle on success
1029
+ * (caller is the leader), or `null` if another holder owns it.
1030
+ *
1031
+ * Implementations MUST be non-blocking — if the lock is held, return
1032
+ * null immediately. The factory polls / retries at a higher layer.
1033
+ */
1034
+ acquire(key: string): Promise<SingletonLockHandle | null>;
1035
+ }
986
1036
 
987
1037
  interface PointIndexerConfig {
988
1038
  provider: PublicClient;
@@ -1190,7 +1240,11 @@ interface BurnIndexerConfig {
1190
1240
  */
1191
1241
  declare class BurnIndexer {
1192
1242
  private readonly provider;
1193
- private readonly pointTokenAddress;
1243
+ /**
1244
+ * The PointToken this indexer watches. Exposed so callers can key
1245
+ * leader-election locks / cursor stores by token (audit H-04 fix).
1246
+ */
1247
+ readonly pointTokenAddress: Address;
1194
1248
  private readonly ledger;
1195
1249
  private readonly cursorStore;
1196
1250
  private readonly startBlock;
@@ -1222,6 +1276,60 @@ declare class BurnIndexer {
1222
1276
  private finalize;
1223
1277
  }
1224
1278
 
1279
+ /**
1280
+ * Minimal duck-typed adapter so we don't drag a TypeORM / pg dep into
1281
+ * the SDK. The caller passes anything that can execute a parameterised
1282
+ * query and yield `{ got: boolean }` rows — TypeORM `DataSource.query`,
1283
+ * a raw `pg` client, knex's `raw`, kysely, etc.
1284
+ *
1285
+ * The helper requires `int8` (signed 64-bit) lock IDs. We derive them
1286
+ * from the lock `key` via a deterministic hash so different replicas
1287
+ * agree on the int regardless of casing / whitespace drift.
1288
+ */
1289
+ interface PostgresQueryRunner {
1290
+ query(sql: string, params?: unknown[]): Promise<unknown>;
1291
+ }
1292
+ /**
1293
+ * Build an `ISingletonLock` backed by PostgreSQL session-level advisory
1294
+ * locks (`pg_try_advisory_lock`).
1295
+ *
1296
+ * Why advisory locks (vs. row-level locks or a leases table):
1297
+ * 1. **Non-blocking** — `pg_try_advisory_lock` returns immediately;
1298
+ * perfect for the "try then idle" pattern the SDK uses on boot.
1299
+ * 2. **Auto-release on connection drop** — if the leader pod crashes,
1300
+ * its PG connection closes, and the lock is freed automatically.
1301
+ * No timeout tuning, no lease renewal loops, no zombie locks.
1302
+ * 3. **Zero schema** — no extra table, no migration, no contention
1303
+ * with application reads.
1304
+ *
1305
+ * **Caveat — long-lived connection required.** Session-level locks are
1306
+ * held only for the lifetime of the PG connection that called
1307
+ * `pg_try_advisory_lock`. PgBouncer in `transaction` pooling mode will
1308
+ * release the lock between statements; you MUST run in `session`
1309
+ * pooling or skip the bouncer for the indexer process. The advisory
1310
+ * `transaction` variant exists but doesn't fit the "hold while polling"
1311
+ * pattern — for that you'd need a periodic re-acquire, which we don't
1312
+ * implement here.
1313
+ *
1314
+ * @example
1315
+ * import { DataSource } from "typeorm";
1316
+ * import { makePostgresSingletonLock } from "@pafi-dev/issuer";
1317
+ *
1318
+ * const dataSource = new DataSource({ ... });
1319
+ * const lock = makePostgresSingletonLock({
1320
+ * query: (sql, params) => dataSource.query(sql, params),
1321
+ * });
1322
+ *
1323
+ * createIssuerService({
1324
+ * ...,
1325
+ * indexer: {
1326
+ * autoStart: true,
1327
+ * singletonLock: lock,
1328
+ * },
1329
+ * });
1330
+ */
1331
+ declare function makePostgresSingletonLock(runner: PostgresQueryRunner): ISingletonLock;
1332
+
1225
1333
  interface ApiConfigResponse {
1226
1334
  chainId: number;
1227
1335
  contracts: {
@@ -2890,8 +2998,27 @@ interface IssuerServiceConfig {
2890
2998
  /**
2891
2999
  * If `true`, the factory calls `indexer.start()` before returning.
2892
3000
  * Default: `false` — the caller decides when to begin polling.
3001
+ *
3002
+ * **SAFETY (H-04):** in a multi-replica deployment, ALWAYS pair
3003
+ * `autoStart: true` with `singletonLock` below. Without leader
3004
+ * election, every replica starts its own indexer fleet and races
3005
+ * against the others — see `ISingletonLock` docs for the
3006
+ * consequences.
2893
3007
  */
2894
3008
  autoStart?: boolean;
3009
+ /**
3010
+ * Leader-election primitive. When provided, the factory wraps
3011
+ * `indexer.start()` with a `singletonLock.acquire(key)` call and
3012
+ * only starts the indexer if it wins the lock. Non-leaders stay
3013
+ * idle and take over on the next acquire attempt (when the leader
3014
+ * pod's connection drops, the lock auto-releases).
3015
+ *
3016
+ * The lock key includes the indexer kind + the PointToken address,
3017
+ * so different tokens can be sharded across replicas if desired.
3018
+ *
3019
+ * Recommended: `makePostgresSingletonLock(dataSource)`.
3020
+ */
3021
+ singletonLock?: ISingletonLock;
2895
3022
  /**
2896
3023
  * Override the MintFeeWrapper address used by the indexer. When
2897
3024
  * omitted, the factory auto-resolves from
@@ -2937,6 +3064,13 @@ interface IssuerService {
2937
3064
  fee: FeeManager | undefined;
2938
3065
  /** All indexers keyed by PointToken address. */
2939
3066
  indexers: Map<Address, PointIndexer>;
3067
+ /**
3068
+ * Lock handles for the indexers this replica was elected leader for.
3069
+ * Empty when `autoStart` is false, or when no `singletonLock` was
3070
+ * provided. Call `release()` on each during graceful shutdown so
3071
+ * peers can take over without waiting for the connection to die.
3072
+ */
3073
+ indexerLeaderLocks: SingletonLockHandle[];
2940
3074
  /** Framework-agnostic HTTP handlers — wire into Express / Fastify / Hono. */
2941
3075
  api: IssuerApiHandlers;
2942
3076
  /**
@@ -2958,7 +3092,7 @@ interface IssuerService {
2958
3092
  *
2959
3093
  * Throws synchronously if any required field is missing.
2960
3094
  */
2961
- declare function createIssuerService(config: IssuerServiceConfig): IssuerService;
3095
+ declare function createIssuerService(config: IssuerServiceConfig): Promise<IssuerService>;
2962
3096
 
2963
3097
  /**
2964
3098
  * Adapter that absorbs every "framework-agnostic" endpoint body into a
@@ -3698,4 +3832,4 @@ declare class MemoryRedemptionHistoryStore implements IRedemptionHistoryStore {
3698
3832
 
3699
3833
  declare const PAFI_ISSUER_SDK_VERSION: string;
3700
3834
 
3701
- export { AdapterMisconfiguredError, type ApiConfigResponse, type ApiGasFeeResponse, type ApiLoginRequest, type ApiLoginResponse, type ApiNonceResponse, type ApiPoolsRequest, type ApiPoolsResponse, type ApiRedemptionEvaluateRequest, type ApiRedemptionEvaluateResponse, type ApiRedemptionPreviewRequest, type ApiRedemptionPreviewResponse, type ApiUserRequest, type ApiUserResponse, type AuthContext, AuthError, type AuthErrorCode, AuthService, type AuthServiceConfig, type BundlerEstimatorClient, BundlerNotConfiguredError, BundlerRejectedError, type BurnEvent, BurnIndexer, type BurnIndexerConfig, type BurnStatusParams, type BurnStatusResponse, type ClaimDto, type ConfigDto, ConfigurationError, DEFAULT_REDEMPTION_POLICY, type DecodedCallDto, DefaultPolicyEngine, type DefaultPolicyEngineOptions, type DelegatePrepareDto, type DelegateStatusDto, type EstimateGasFeeOptions, type EvaluateInput, FeeManager, type FeeManagerConfig, type FeeManagerMetrics, type FetchFailureReason, type FetchImpl, type FetchResult, type GasFeeDto, type GasFeeSource, type HandleDelegateSubmitParams, type HandleDelegateSubmitResult, type HandleMobilePrepareParams, type HandleMobilePrepareResult, type HandleMobileSubmitParams, type IIndexerCursorStore, type IPendingUserOpStore, type IPointLedger, type IPolicyEngine, type IRateLimiter, type IRedemptionHistoryStore, type ISessionStore, InMemoryCursorStore, IssuerApiAdapter, type IssuerApiAdapterConfig, IssuerApiHandlers, type IssuerApiHandlersConfig, type IssuerRegistryRecord, type IssuerService, type IssuerServiceConfig, IssuerStateError, IssuerStateValidator, LockNotFoundError, type LockedMintRequest, type LoginResult, MemoryPendingUserOpStore, MemoryRateLimiter, MemoryRedemptionHistoryStore, MemorySessionStore, type MemorySessionStoreOptions, type MintEvent, type MintStatusParams, type MintStatusResponse, type MintingStatus, type MobilePrepareDto, type MobileSubmitDto, type NativePtQuoterConfig, NonceManager, NoopRateLimiter, PAFI_ISSUER_SDK_VERSION, PTClaimError, PTClaimHandler, type PTClaimHandlerConfig, type PTClaimRequest, type PTClaimResponse, PTRedeemError, PTRedeemHandler, type PTRedeemHandlerConfig, type PTRedeemRequest, type PTRedeemResponse, PafiBackendClient, type PafiBackendConfig, PafiBackendError, type PafiBackendErrorCode, type PafiEstimatorClientConfig, PafiEstimatorHttpError, type PaymasterGasEstimates, type PendingCredit, type PendingUserOpEntry, PendingUserOpForbiddenError, PendingUserOpNotFoundError, type PerpDepositDto, PerpDepositError, PerpDepositHandler, type PerpDepositHandlerConfig, type PerpDepositRequest, type PerpDepositResponse, PointIndexer, type PointIndexerConfig, PointTokenDomainResolver, type PointTokenDomainResolverConfig, type PolicyDecision, type PolicyEvalRequest, PolicyProvider, type PolicyProviderConfig, type PoolsDto, type PoolsProvider, type PreValidateMintResult, type PrepareBurnParams, type PrepareMintParams, type PrepareMobileUserOpParams, type PrepareMobileUserOpResult, type PreparedUserOp, type PreviewBurnParams, type PreviewMintParams, REDEMPTION_HISTORY_WINDOW_SEC, type RateLimitAction, type RateLimiterConfig, type RedeemDto, type RedeemPrepareDto, RedemptionService, type RedemptionServiceConfig, RelayError, type RelayErrorCode, RelayService, type RelayUserOpParams, type RelayUserOpRequest, type RelayUserOpResponse, type RequestPaymasterParams, type ResolvedPolicy, type RetryConfig, type SdkErrorBody, type SdkErrorMapperFactories, type SdkErrorStatus, type SerializedUserOpTypedData, type Session, SettlementClient, type SettlementClientConfig, type SponsorshipRequest, type SponsorshipResponse, type SponsorshipTarget, type SponsorshipUserOp, type SubgraphNativeUsdtQuoterConfig, type SubgraphPoolsProviderConfig, type UserDto, type UserHistory, applyPaymasterGasEstimates, authenticateRequest, buildSdkErrorBody, createIssuerService, createNativePtQuoter, createPafiEstimatorClient, createSdkErrorMapper, createSubgraphNativeUsdtQuoter, createSubgraphPoolsProvider, defaultPolicyFor, evaluateRedemption, handleClaimStatus, handleDelegateSubmit, handleMobilePrepare, handleMobileSubmit, handleRedeemStatus, mergePaymasterFields, prepareMobileUserOp, relayUserOp, requestPaymaster, serializeEntryToJsonRpc, serializeUserOpTypedData };
3835
+ export { AdapterMisconfiguredError, type ApiConfigResponse, type ApiGasFeeResponse, type ApiLoginRequest, type ApiLoginResponse, type ApiNonceResponse, type ApiPoolsRequest, type ApiPoolsResponse, type ApiRedemptionEvaluateRequest, type ApiRedemptionEvaluateResponse, type ApiRedemptionPreviewRequest, type ApiRedemptionPreviewResponse, type ApiUserRequest, type ApiUserResponse, type AuthContext, AuthError, type AuthErrorCode, AuthService, type AuthServiceConfig, type BundlerEstimatorClient, BundlerNotConfiguredError, BundlerRejectedError, type BurnEvent, BurnIndexer, type BurnIndexerConfig, type BurnStatusParams, type BurnStatusResponse, type ClaimDto, type ConfigDto, ConfigurationError, DEFAULT_REDEMPTION_POLICY, type DecodedCallDto, DefaultPolicyEngine, type DefaultPolicyEngineOptions, type DelegatePrepareDto, type DelegateStatusDto, type EstimateGasFeeOptions, type EvaluateInput, FeeManager, type FeeManagerConfig, type FeeManagerMetrics, type FetchFailureReason, type FetchImpl, type FetchResult, type GasFeeDto, type GasFeeSource, type HandleDelegateSubmitParams, type HandleDelegateSubmitResult, type HandleMobilePrepareParams, type HandleMobilePrepareResult, type HandleMobileSubmitParams, type IIndexerCursorStore, type IPendingUserOpStore, type IPointLedger, type IPolicyEngine, type IRateLimiter, type IRedemptionHistoryStore, type ISessionStore, type ISingletonLock, InMemoryCursorStore, IssuerApiAdapter, type IssuerApiAdapterConfig, IssuerApiHandlers, type IssuerApiHandlersConfig, type IssuerRegistryRecord, type IssuerService, type IssuerServiceConfig, IssuerStateError, IssuerStateValidator, LockNotFoundError, type LockedMintRequest, type LoginResult, MemoryPendingUserOpStore, MemoryRateLimiter, MemoryRedemptionHistoryStore, MemorySessionStore, type MemorySessionStoreOptions, type MintEvent, type MintStatusParams, type MintStatusResponse, type MintingStatus, type MobilePrepareDto, type MobileSubmitDto, type NativePtQuoterConfig, NonceManager, NoopRateLimiter, PAFI_ISSUER_SDK_VERSION, PTClaimError, PTClaimHandler, type PTClaimHandlerConfig, type PTClaimRequest, type PTClaimResponse, PTRedeemError, PTRedeemHandler, type PTRedeemHandlerConfig, type PTRedeemRequest, type PTRedeemResponse, PafiBackendClient, type PafiBackendConfig, PafiBackendError, type PafiBackendErrorCode, type PafiEstimatorClientConfig, PafiEstimatorHttpError, type PaymasterGasEstimates, type PendingCredit, type PendingUserOpEntry, PendingUserOpForbiddenError, PendingUserOpNotFoundError, type PerpDepositDto, PerpDepositError, PerpDepositHandler, type PerpDepositHandlerConfig, type PerpDepositRequest, type PerpDepositResponse, PointIndexer, type PointIndexerConfig, PointTokenDomainResolver, type PointTokenDomainResolverConfig, type PolicyDecision, type PolicyEvalRequest, PolicyProvider, type PolicyProviderConfig, type PoolsDto, type PoolsProvider, type PostgresQueryRunner, type PreValidateMintResult, type PrepareBurnParams, type PrepareMintParams, type PrepareMobileUserOpParams, type PrepareMobileUserOpResult, type PreparedUserOp, type PreviewBurnParams, type PreviewMintParams, REDEMPTION_HISTORY_WINDOW_SEC, type RateLimitAction, type RateLimiterConfig, type RedeemDto, type RedeemPrepareDto, RedemptionService, type RedemptionServiceConfig, RelayError, type RelayErrorCode, RelayService, type RelayUserOpParams, type RelayUserOpRequest, type RelayUserOpResponse, type RequestPaymasterParams, type ResolvedPolicy, type RetryConfig, type SdkErrorBody, type SdkErrorMapperFactories, type SdkErrorStatus, type SerializedUserOpTypedData, type Session, SettlementClient, type SettlementClientConfig, type SingletonLockHandle, type SponsorshipRequest, type SponsorshipResponse, type SponsorshipTarget, type SponsorshipUserOp, type SubgraphNativeUsdtQuoterConfig, type SubgraphPoolsProviderConfig, type UserDto, type UserHistory, applyPaymasterGasEstimates, authenticateRequest, buildSdkErrorBody, createIssuerService, createNativePtQuoter, createPafiEstimatorClient, createSdkErrorMapper, createSubgraphNativeUsdtQuoter, createSubgraphPoolsProvider, defaultPolicyFor, evaluateRedemption, handleClaimStatus, handleDelegateSubmit, handleMobilePrepare, handleMobileSubmit, handleRedeemStatus, makePostgresSingletonLock, mergePaymasterFields, prepareMobileUserOp, relayUserOp, requestPaymaster, serializeEntryToJsonRpc, serializeUserOpTypedData };
package/dist/index.js CHANGED
@@ -1391,6 +1391,10 @@ var DEFAULT_BATCH_SIZE2 = 2000n;
1391
1391
  var DEFAULT_POLL_INTERVAL_MS2 = 5e3;
1392
1392
  var BurnIndexer = class {
1393
1393
  provider;
1394
+ /**
1395
+ * The PointToken this indexer watches. Exposed so callers can key
1396
+ * leader-election locks / cursor stores by token (audit H-04 fix).
1397
+ */
1394
1398
  pointTokenAddress;
1395
1399
  ledger;
1396
1400
  cursorStore;
@@ -1550,6 +1554,45 @@ var BurnIndexer = class {
1550
1554
  }
1551
1555
  };
1552
1556
 
1557
+ // src/indexer/postgresSingletonLock.ts
1558
+ function makePostgresSingletonLock(runner) {
1559
+ return {
1560
+ async acquire(key) {
1561
+ const lockId = hashKeyToInt64(key);
1562
+ const rows = await runner.query(
1563
+ "SELECT pg_try_advisory_lock($1::bigint) AS got",
1564
+ [lockId]
1565
+ );
1566
+ const got = rows[0]?.got === true;
1567
+ if (!got) return null;
1568
+ return {
1569
+ async release() {
1570
+ try {
1571
+ await runner.query("SELECT pg_advisory_unlock($1::bigint)", [
1572
+ lockId
1573
+ ]);
1574
+ } catch {
1575
+ }
1576
+ }
1577
+ };
1578
+ }
1579
+ };
1580
+ }
1581
+ function hashKeyToInt64(key) {
1582
+ const FNV_OFFSET = 0xcbf29ce484222325n;
1583
+ const FNV_PRIME = 0x100000001b3n;
1584
+ const MASK_64 = (1n << 64n) - 1n;
1585
+ let hash = FNV_OFFSET;
1586
+ for (let i = 0; i < key.length; i++) {
1587
+ hash ^= BigInt(key.charCodeAt(i));
1588
+ hash = hash * FNV_PRIME & MASK_64;
1589
+ }
1590
+ const SIGNED_MAX = (1n << 63n) - 1n;
1591
+ const TWO64 = 1n << 64n;
1592
+ const signed = hash > SIGNED_MAX ? hash - TWO64 : hash;
1593
+ return signed.toString();
1594
+ }
1595
+
1553
1596
  // src/api/handlers.ts
1554
1597
  import { getAddress as getAddress5 } from "viem";
1555
1598
  import {
@@ -2861,8 +2904,16 @@ var PTClaimHandler = class {
2861
2904
  domain,
2862
2905
  mintRequestNonce: request.mintRequestNonce,
2863
2906
  deadline: signatureDeadline,
2864
- mintFeeWrapperAddress: resolvedWrapper
2865
- // No feeAmount/feeRecipient RelayService auto-resolves.
2907
+ mintFeeWrapperAddress: resolvedWrapper,
2908
+ // Pass the bundler-estimated `feeAmount` explicitly so the
2909
+ // RelayService skips its legacy `quoteOperatorFeePt` path
2910
+ // (which uses the SDK's old 12_000 bps premium default).
2911
+ // Without this, the response's `feeAmount` (from FeeManager,
2912
+ // 100% premium on top of PAFI's 110% server-side estimate)
2913
+ // would diverge from the actual PT.transfer amount in the
2914
+ // UserOp batch (`quoteOperatorFeePt`'s 120%), and the user
2915
+ // would see one value while the wallet transferred another.
2916
+ feeAmount
2866
2917
  });
2867
2918
  } catch (err) {
2868
2919
  throw new PTClaimError(
@@ -4469,7 +4520,7 @@ var RedemptionService = class {
4469
4520
  };
4470
4521
 
4471
4522
  // src/config.ts
4472
- function createIssuerService(config) {
4523
+ async function createIssuerService(config) {
4473
4524
  if (!config.provider) {
4474
4525
  throw new Error("createIssuerService: provider is required");
4475
4526
  }
@@ -4588,9 +4639,26 @@ function createIssuerService(config) {
4588
4639
  handlersConfig.mintFeeWrapperAddress = resolvedWrapperAddress;
4589
4640
  }
4590
4641
  const handlers = new IssuerApiHandlers(handlersConfig);
4642
+ const indexerLeaderLocks = [];
4591
4643
  if (config.indexer?.autoStart) {
4592
- for (const idx of indexers.values()) {
4593
- idx.start();
4644
+ const lock = config.indexer.singletonLock;
4645
+ if (!lock) {
4646
+ console.warn(
4647
+ "[@pafi-dev/issuer] indexer.autoStart=true without singletonLock \u2014 this is UNSAFE in multi-replica deployments (audit finding H-04). Either set replicas=1 + INDEXER_AUTOSTART=false on non-leader pods, or pass `singletonLock: makePostgresSingletonLock(dataSource)`. This permissive path will be removed in a future major release."
4648
+ );
4649
+ for (const idx of indexers.values()) {
4650
+ idx.start();
4651
+ }
4652
+ } else {
4653
+ for (const [tokenAddr, idx] of indexers.entries()) {
4654
+ const key = `pafi-issuer:point-indexer:${tokenAddr.toLowerCase()}`;
4655
+ const handle = await lock.acquire(key);
4656
+ if (!handle) {
4657
+ continue;
4658
+ }
4659
+ idx.start();
4660
+ indexerLeaderLocks.push(handle);
4661
+ }
4594
4662
  }
4595
4663
  }
4596
4664
  return {
@@ -4601,6 +4669,7 @@ function createIssuerService(config) {
4601
4669
  relay: relayService,
4602
4670
  fee: feeManager,
4603
4671
  indexers,
4672
+ indexerLeaderLocks,
4604
4673
  api: handlers,
4605
4674
  redemption
4606
4675
  };
@@ -4813,7 +4882,7 @@ var MemoryRedemptionHistoryStore = class {
4813
4882
  };
4814
4883
 
4815
4884
  // src/index.ts
4816
- var PAFI_ISSUER_SDK_VERSION = true ? "0.21.0" : "dev";
4885
+ var PAFI_ISSUER_SDK_VERSION = true ? "0.22.0" : "dev";
4817
4886
  export {
4818
4887
  AdapterMisconfiguredError,
4819
4888
  AuthError,
@@ -4879,6 +4948,7 @@ export {
4879
4948
  handleMobilePrepare,
4880
4949
  handleMobileSubmit,
4881
4950
  handleRedeemStatus,
4951
+ makePostgresSingletonLock,
4882
4952
  mergePaymasterFields,
4883
4953
  payloadFromGenericError,
4884
4954
  payloadFromHttpException,