@pafi-dev/issuer 0.12.7 → 0.15.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/README.md CHANGED
@@ -4,25 +4,30 @@
4
4
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
5
5
 
6
6
  Backend SDK for PAFI issuer servers — claim, redeem, perp deposit,
7
- mobile prepare/submit, EIP-7702 delegation, status polling, and the
8
- `IssuerApiAdapter` that thins issuer controllers to one line per
9
- endpoint.
7
+ mobile prepare/submit, EIP-7702 delegation, status polling, redemption
8
+ restriction enforcement, and the `IssuerApiAdapter` that thins issuer
9
+ controllers to one line per endpoint.
10
10
 
11
11
  **Server-only.** Pulls in signer wallets, HTTP clients, ledger
12
12
  interfaces. Don't bundle into a browser app — use
13
- `@pafi-dev/trading` (FE swap/quote) or `@pafi-dev/core` (primitives)
14
- instead.
13
+ `@pafi-dev/trading` (FE swap/quote) or `@pafi-dev/core` (primitives).
15
14
 
16
15
  ---
17
16
 
17
+ ## v0.15.0 — Uniswap V3 migration (breaking)
18
+
19
+ - `createSubgraphPoolsProvider` now returns the V3 `PoolKey` shape (`{ token0, token1, fee }`) instead of the V4 shape (`{ currency0, currency1, fee, tickSpacing, hooks }`). Pulls through from `@pafi-dev/core`.
20
+ - New `onError?: (err: Error) => void` option — forward recoverable errors (network, GraphQL, parse) to your observability stack. Throws inside the callback are swallowed so the provider stays total.
21
+ - `feeTier` is range-checked (uint24, < 1_000_000) before building the `PoolKey`; invalid values are skipped with a console.error + `onError` invocation.
22
+ - Subgraph endpoint default → `…/pafi-subgraph-v4` (PAFI V3-fork DEX + extensions); see `@pafi-dev/core` for the constant.
23
+ - Bumps peer-deps on `@pafi-dev/core` to `^0.13.0` (V3 types + ABIs).
24
+
18
25
  ## Requirements
19
26
 
20
- - Node.js >= 18
21
- - TypeScript >= 5.0
27
+ - Node.js 18
28
+ - TypeScript 5.0
22
29
  - `viem` ^2.0.0 (peer)
23
- - `@pafi-dev/core` (peer — re-exported types)
24
-
25
- ---
30
+ - `@pafi-dev/core` ^0.13.0 (transitive — re-exported)
26
31
 
27
32
  ## Installation
28
33
 
@@ -38,11 +43,9 @@ pnpm add @pafi-dev/issuer-postgres typeorm pg
38
43
 
39
44
  ```
40
45
  @pafi-dev/issuer
41
- ├── handlers — flow handlers (PTClaimHandler, PTRedeemHandler,
42
- │ PerpDepositHandler)
46
+ ├── handlers — PTClaimHandler, PTRedeemHandler, PerpDepositHandler
43
47
  ├── api/IssuerApiAdapter
44
- │ — single class, every endpoint is one line in
45
- │ your controller
48
+ │ — single class, every endpoint is one line in controller
46
49
  ├── api/handleMobilePrepare/Submit
47
50
  │ — mobile claim/redeem orchestrators
48
51
  ├── api/handleClaimStatus/handleRedeemStatus
@@ -55,20 +58,15 @@ pnpm add @pafi-dev/issuer-postgres typeorm pg
55
58
  ├── userop-store — IPendingUserOpStore + MemoryPendingUserOpStore
56
59
  ├── pafi-backend — sponsor-relayer client + relay/paymaster helpers
57
60
  ├── auth — ISessionStore, AuthService (SIWE), MemorySessionStore
58
- ├── relay — RelayService, FeeManager
61
+ ├── relay — RelayService (wrapper-aware), FeeManager
59
62
  ├── pools — createSubgraphPoolsProvider, NativePtQuoter
60
63
  ├── policy — IPolicyEngine + DefaultPolicyEngine
61
- ├── balanceBalanceAggregator (off-chain + on-chain)
62
- ├── indexer PointIndexer, BurnIndexer (singleton workers)
63
- ├── issuer-state IssuerStateValidator (registry + cap pre-check)
64
+ ├── indexerPointIndexer (wrapper + direct mode), BurnIndexer
65
+ ├── issuer-state IssuerStateValidator (7-field struct + oracle.tokenCaps)
66
+ ├── redemption RedemptionService (per-issuer policy enforcement)
64
67
  └── errors — PafiSdkError base class for typed errors
65
68
  ```
66
69
 
67
- > **Removed in 0.6.0** (2026-04-27): `SwapHandler`, `quotePointTokenToUsdt`,
68
- > and `IssuerApiAdapter.swap()/quote()` — moved to `@pafi-dev/trading`.
69
- > FE PAFI calls trading directly. `IssuerApiHandlers.handleClaim/handleRedeem`
70
- > legacy methods + `TopUpRedemptionHandler` (Variant B) also dropped.
71
-
72
70
  ---
73
71
 
74
72
  ## Architecture
@@ -80,23 +78,63 @@ Auth guard ← issuer-specific (SIWE / Privy / NextAuth)
80
78
 
81
79
  Controller ← thin: routing + DTO + auth context
82
80
 
83
- IssuerApiAdapter ← @pafi-dev/issuer (orchestrates flows)
81
+ IssuerApiAdapter ← orchestrates flows
84
82
 
85
- PTClaimHandler ← signs MintRequest, locks balance, builds UserOp
83
+ PTClaimHandler ← signs MintForRequest, locks balance, builds UserOp
84
+ (routes to wrapper.mintWithFee when wrapper configured)
86
85
  PTRedeemHandler ← signs BurnRequest, reserves credit, builds UserOp
87
86
  PerpDepositHandler ← Orderly Vault via PAFI Relay
88
87
 
89
- PostgresPointLedger @pafi-dev/issuer-postgres (TypeORM)
90
- RelayService ← UserOp builders for mint/burn
88
+ IPointLedger your DB impl (Postgres recommended)
89
+ RelayService ← UserOp builders (auto-branches direct vs wrapper)
91
90
  PafiBackendClient ← sponsor-relayer proxy
92
91
  ```
93
92
 
94
93
  ---
95
94
 
95
+ ## Wrapper-mediated mint flow
96
+
97
+ ```
98
+ [FE / mobile] → POST /claim/prepare
99
+
100
+ [gg56] IssuerApiAdapter.claimPrepare()
101
+
102
+ [gg56] PTClaimHandler:
103
+ 1. Lock off-chain balance (gross amount)
104
+ 2. Pre-validate via IssuerStateValidator (oracle cap check)
105
+ 3. Sign MintForRequest EIP-712 with receiver = wrapper
106
+ 4. Build UserOp callData:
107
+ BatchExecute([
108
+ { mintFeeWrapper, mintWithFee(pt, user, gross, deadline, sig) },
109
+ { pointToken, transfer(pafiFeeRecipient, operatorFeePT) }
110
+ ])
111
+
112
+ [gg56] PafiBackendClient.requestSponsorship() → sponsor-relayer
113
+
114
+ [sponsor-relayer] decode calldata, recognize MINT_WITH_FEE (0x5284d08b),
115
+ validate intent, forward to Pimlico paymaster
116
+
117
+ [gg56] returns { userOp, typedData, userOpHash, sponsored: true|false,
118
+ typedDataFallback, userOpHashFallback }
119
+
120
+ [FE / mobile] sign typedData via signTypedData_v4
121
+
122
+ [FE / mobile] POST /claim/submit { lockId, signature, variant }
123
+
124
+ [gg56] forwards signed UserOp to Pimlico bundler → on-chain mint
125
+
126
+ [chain] wrapper.mintWithFee → PointToken.mint(gross to wrapper) →
127
+ wrapper splits fee to recipients → transfers net to user
128
+
129
+ [gg56] PointIndexer (wrapper mode) catches MintWithFee event →
130
+ deduct off-chain balance, lock → MINTED
131
+ ```
132
+
133
+ ---
134
+
96
135
  ## Quick start (NestJS)
97
136
 
98
- Minimal reference at `examples/nestjs-issuer/` — full working backend
99
- with all 11 endpoints, ~940 LoC total.
137
+ Minimal reference at `examples/nestjs-issuer/` — full working backend.
100
138
 
101
139
  ### 1. Wire `IssuerApiAdapter`
102
140
 
@@ -109,7 +147,6 @@ import {
109
147
  PTRedeemHandler,
110
148
  PerpDepositHandler,
111
149
  MemoryPendingUserOpStore,
112
- createSubgraphPoolsProvider,
113
150
  type IssuerService,
114
151
  } from "@pafi-dev/issuer";
115
152
  import { PostgresPointLedger } from "@pafi-dev/issuer-postgres";
@@ -117,41 +154,48 @@ import { getContractAddresses } from "@pafi-dev/core";
117
154
 
118
155
  export const issuerApiAdapterProvider: Provider = {
119
156
  provide: ISSUER_API_ADAPTER,
120
- useFactory: (issuerService, provider, walletClient, dataSource) => {
157
+ useFactory: (issuerService, provider, walletClient, dataSource, config) => {
121
158
  const ledger = new PostgresPointLedger(dataSource);
122
159
  const { issuerRegistry, batchExecutor } = getContractAddresses(8453);
160
+ const chainId = config.get<number>("CHAIN_ID");
161
+ const pointToken = config.get<`0x${string}`>("POINT_TOKEN_ADDRESS");
123
162
 
124
163
  return new IssuerApiAdapter({
125
164
  issuerService,
126
165
  ledger,
127
166
  provider,
128
167
  issuerSignerWallet: walletClient,
129
- pafiIssuerId: process.env.PAFI_ISSUER_ID,
168
+ pafiIssuerId: config.get("PAFI_ISSUER_ID"),
130
169
 
131
170
  ptClaimHandler: new PTClaimHandler({
132
- ledger, relayService: issuerService.relay, provider,
171
+ ledger,
172
+ relayService: issuerService.relay,
173
+ provider,
133
174
  issuerSignerWallet: walletClient,
134
- pointTokenDomainName: "POINT",
175
+ pointTokenDomainName: config.get("POINT_TOKEN_DOMAIN_NAME"),
135
176
  feeService: issuerService.fee,
136
177
  issuerStateValidator: new IssuerStateValidator(provider, issuerRegistry),
178
+ // mintFeeWrapperAddress: optional override; SDK auto-resolves from chainId
137
179
  }),
138
180
 
139
181
  ptRedeemHandler: new PTRedeemHandler({
140
- ledger, relayService: issuerService.relay, provider,
141
- pointTokenAddress: POINT_TOKEN,
182
+ ledger,
183
+ relayService: issuerService.relay,
184
+ provider,
185
+ pointTokenAddress: pointToken,
142
186
  batchExecutorAddress: batchExecutor,
143
- chainId: 8453,
144
- domain: { name: "POINT", verifyingContract: POINT_TOKEN },
187
+ chainId,
188
+ domain: { name: config.get("POINT_TOKEN_DOMAIN_NAME"), verifyingContract: pointToken },
145
189
  burnerSignerWallet: walletClient,
146
190
  feeService: issuerService.fee,
147
191
  }),
148
192
 
149
193
  perpHandler: new PerpDepositHandler({
150
- provider, feeService: issuerService.fee, pointTokenAddress: POINT_TOKEN,
194
+ provider, feeService: issuerService.fee, pointTokenAddress: pointToken,
151
195
  }),
152
196
 
153
197
  pendingUserOpStore: new MemoryPendingUserOpStore(),
154
- pafiBackendClient, // optional — required for mobile submit / sponsor-relayer
198
+ pafiBackendClient, // optional — sponsor-relayer client for sponsored flows
155
199
  });
156
200
  },
157
201
  inject: [...],
@@ -183,7 +227,6 @@ export class IssuerController {
183
227
  aaNonce, mintRequestNonce,
184
228
  }));
185
229
  }
186
- // ... other endpoints similarly thin
187
230
  }
188
231
  ```
189
232
 
@@ -191,16 +234,12 @@ export class IssuerController {
191
234
 
192
235
  ```ts
193
236
  import { createSdkErrorMapper, type SdkErrorBody } from "@pafi-dev/issuer";
194
- import {
195
- NotFoundException, ForbiddenException,
196
- UnprocessableEntityException, ServiceUnavailableException,
197
- } from "@nestjs/common";
198
-
199
- const sdkErrorMapper: (err: unknown) => never = createSdkErrorMapper({
200
- notFound: (b: SdkErrorBody) => new NotFoundException(b),
201
- forbidden: (b: SdkErrorBody) => new ForbiddenException(b),
202
- unprocessable: (b: SdkErrorBody) => new UnprocessableEntityException(b),
203
- serviceUnavailable: (b: SdkErrorBody) => new ServiceUnavailableException(b),
237
+
238
+ const sdkErrorMapper = createSdkErrorMapper({
239
+ notFound: (b) => new NotFoundException(b),
240
+ forbidden: (b) => new ForbiddenException(b),
241
+ unprocessable: (b) => new UnprocessableEntityException(b),
242
+ serviceUnavailable: (b) => new ServiceUnavailableException(b),
204
243
  });
205
244
 
206
245
  async function wrap<T>(fn: () => Promise<T>): Promise<T> {
@@ -209,91 +248,129 @@ async function wrap<T>(fn: () => Promise<T>): Promise<T> {
209
248
  }
210
249
  ```
211
250
 
212
- Every typed SDK error (`PafiSdkError` subclass) routes through this
213
- funnel — `code`, `safeToRetry`, `details`, `httpStatus` come straight
214
- off the error class. No magic strings, no per-error mapping.
215
-
216
251
  ---
217
252
 
218
253
  ## `IssuerApiAdapter` methods
219
254
 
220
- | Method | HTTP route in your controller |
221
- | --- | --- |
222
- | `pools(authedAddr, chainId, pointToken)` | `GET /pools` |
223
- | `user(authedAddr, chainId, userAddr, pointToken)` | `GET /user` |
224
- | `claim({ ...nonces, ... })` | `POST /claim` (web sync `calls[]`) |
225
- | `redeem({ amount, aaNonce, ... })` | `POST /redeem` |
226
- | `perpDeposit({ amount, brokerId, aaNonce, ... })` | `POST /perp-deposit` |
227
- | `claimPrepare(...)` / `claimSubmit(...)` | Mobile claim flow |
228
- | `redeemPrepare(...)` / `redeemSubmit(...)` | Mobile redeem flow |
229
- | `claimStatus(authedAddr, lockId)` / `redeemStatus(...)` | Status polling |
230
- | `delegateStatus(authedAddr, chainId)` | EIP-7702 check delegation installed |
231
- | `delegatePrepare(authedAddr, chainId)` | EIP-7702 — build authorization hash |
232
- | `delegateSubmit({ authSig, delegationNonce, aaNonce, ... })` | EIP-7702 — relay empty-batch UserOp |
233
-
234
- > `swap()` and `quote()` removed in 0.6.0 — see `@pafi-dev/trading`.
235
- > `config()` and `gasFee()` are optional read endpoints; FE can call
236
- > `getContractAddresses()` and `quoteOperatorFeeUsdt()` from
237
- > `@pafi-dev/core` directly with no backend round-trip.
255
+ | Method | HTTP route | Notes |
256
+ | --- | --- | --- |
257
+ | `pools(authedAddr, chainId, pointToken)` | `GET /pools` | |
258
+ | `user(authedAddr, chainId, userAddr, pointToken)` | `GET /user` | |
259
+ | `config(chainId)` | `GET /config` | Includes `mintFeeBpsByToken` + `contracts.mintFeeWrapper` |
260
+ | `claim({ ...nonces })` | `POST /claim` (web — sync `calls[]`) | |
261
+ | `redeem({ amount, aaNonce, ... })` | `POST /redeem` | |
262
+ | `perpDeposit({ amount, brokerId, aaNonce, ... })` | `POST /perp-deposit` | |
263
+ | `claimPrepare(...)` / `claimSubmit(...)` | Mobile claim flow | wrapper-aware |
264
+ | `redeemPrepare(...)` / `redeemSubmit(...)` | Mobile redeem flow | |
265
+ | `claimStatus(authedAddr, lockId)` / `redeemStatus(...)` | Status polling | |
266
+ | `delegateStatus(authedAddr, chainId)` | EIP-7702 — check delegation | |
267
+ | `delegatePrepare(authedAddr, chainId)` | EIP-7702 — build auth hash | |
268
+ | `delegateSubmit({ authSig, ... })` | EIP-7702 — relay empty-batch | |
238
269
 
239
270
  ---
240
271
 
241
- ## Mobile prepare/submit flow
272
+ ## Redemption restriction (v0.10+)
242
273
 
243
- Mobile clients (Privy expo) can't `useSign7702Authorization` they
244
- sign just the `userOpHash` via `personal_sign`. The adapter handles
245
- the orchestration:
274
+ Per-issuer policy enforced at `/redeem/prepare` time. Fetched from
275
+ PAFI issuer-api with 5-min cache + fail-open default.
246
276
 
247
277
  ```ts
248
- // 1. mobile sends POST /claim/prepare → backend runs:
249
- const prepared = await api.claimPrepare({ authenticatedAddress, chainId, pointTokenAddress, amount, aaNonce, mintRequestNonce });
250
- // returns: { lockId, userOpHash, typedData, userOpHashFallback?, typedDataFallback?, sponsored, needsDelegation, ... }
278
+ import { RedemptionService, PolicyProvider } from "@pafi-dev/issuer";
279
+ import { PostgresRedemptionHistoryStore } from "@pafi-dev/issuer-postgres";
280
+
281
+ const redemption = new RedemptionService({
282
+ policyProvider: new PolicyProvider({
283
+ chainId: 8453,
284
+ issuerId: "gg56",
285
+ apiKey: process.env.PAFI_API_KEY,
286
+ }),
287
+ historyStore: new PostgresRedemptionHistoryStore(dataSource),
288
+ });
289
+ ```
290
+
291
+ Wire to `createIssuerService({ redemption: {...} })`. Adapter exposes
292
+ `/redemption/preview` + `/redemption/evaluate` automatically.
293
+
294
+ ---
251
295
 
252
- // 2. mobile signs prepared.userOpHash via Privy personal_sign
296
+ ## Mobile prepare/submit flow
253
297
 
254
- // 3. mobile sends POST /claim/submit → backend runs:
255
- const result = await api.claimSubmit({ authenticatedAddress, lockId, signature, variant: "sponsored" | "fallback" });
298
+ ```ts
299
+ // 1. mobile POST /claim/prepare backend runs:
300
+ const prepared = await api.claimPrepare({
301
+ authenticatedAddress, chainId, pointTokenAddress, amount, aaNonce, mintRequestNonce,
302
+ });
303
+ // returns: {
304
+ // lockId, userOpHash, typedData,
305
+ // userOpHashFallback, typedDataFallback,
306
+ // sponsored, // true if paymaster signed; false → use fallback
307
+ // needsDelegation, // true if user EOA not delegated yet
308
+ // feeAmount, signatureDeadline, expiresInSeconds,
309
+ // }
310
+
311
+ // 2. mobile signs typedData via Privy/MetaMask signTypedData_v4
312
+ // (NOT personal_sign — Pimlico Simple7702Account does raw ecrecover)
313
+ // If `sponsored: false`, sign `typedDataFallback` instead
314
+
315
+ // 3. mobile → POST /claim/submit:
316
+ const result = await api.claimSubmit({
317
+ authenticatedAddress, lockId, signature,
318
+ variant: prepared.sponsored ? "sponsored" : "fallback",
319
+ });
256
320
  // returns: { userOpHash } — bundler hash for status polling
257
321
  ```
258
322
 
259
323
  `handleMobileSubmit` enforces ownership: `entry.sender ===
260
- authenticatedAddress`. Without this, user A could submit user B's
261
- pending UserOp once they leak/guess the lockId.
324
+ authenticatedAddress`. Lock has 15-min TTL.
262
325
 
263
326
  ---
264
327
 
265
- ## Status polling
328
+ ## PointIndexer modes
266
329
 
267
330
  ```ts
268
- // GET /claim/status/:lockId
269
- const status = await api.claimStatus(user.userAddress, lockId);
270
- // { lockId, status: 'PENDING' | 'MINTED' | 'EXPIRED' | 'FAILED', txHash, ... }
331
+ // Default — auto-resolves wrapper from chainId
332
+ const service = createIssuerService({
333
+ chainId: 8453,
334
+ // ...
335
+ indexer: { autoStart: true, pollIntervalMs: 5000 },
336
+ });
337
+
338
+ // Indexer listens to:
339
+ // - mode wrapper: MintFeeWrapper.MintWithFee filtered by pointToken
340
+ // (when wrapper is configured at chainId)
341
+ // - mode direct: PointToken.Transfer(0x0 → user) (legacy / no wrapper)
271
342
  ```
272
343
 
273
- Falls back to bundler receipt when `status === 'PENDING'` and
274
- `userOpHash` is bound — bypasses `PointIndexer`'s amount-match race
275
- (multiple PENDING locks with the same amount can be resolved to the
276
- wrong tx_hash).
344
+ Override for fork tests:
345
+ ```ts
346
+ indexer: { mintFeeWrapperAddress: "0x000...dead" } // force direct mode
347
+ ```
277
348
 
278
349
  ---
279
350
 
280
351
  ## Error classes
281
352
 
282
- All SDK errors inherit `PafiSdkError`. Subclasses + their HTTP status:
353
+ All SDK errors inherit `PafiSdkError`. Subclasses + HTTP mapping:
283
354
 
284
355
  | Error | `httpStatus` | `safeToRetry` |
285
356
  | --- | --- | --- |
286
357
  | `PendingUserOpNotFoundError` | `not_found` | false |
287
358
  | `PendingUserOpForbiddenError` | `forbidden` | false |
288
359
  | `LockNotFoundError` | `not_found` | false |
289
- | `IssuerStateError` | `unprocessable` | true if `MINT_CAP_EXCEEDED` |
360
+ | `IssuerStateError` (`ISSUER_NOT_REGISTERED` / `ISSUER_INACTIVE` / `MINT_CAP_EXCEEDED`) | `unprocessable` | true if cap exceeded |
290
361
  | `PerpDepositError` | `unprocessable` | true if `RELAY_FEE_EXCEEDS_AMOUNT` |
291
362
  | `PTClaimError`, `PTRedeemError` | `unprocessable` | false |
292
363
  | `BundlerNotConfiguredError` | `service_unavailable` | false |
293
364
  | `BundlerRejectedError` | `unprocessable` | false |
365
+ | `RedemptionPolicyError` (`REDEMPTION_DENIED` / `RATE_LIMIT_EXCEEDED` / ...) | `unprocessable` | varies |
294
366
 
295
367
  ---
296
368
 
369
+ ## References
370
+
371
+ - Architecture: [`ARCHITECTURE.md`](../../ARCHITECTURE.md) at SDK root
372
+ - Fee flow & math: [`docs/FEE_FLOW.md`](../../../docs/FEE_FLOW.md)
373
+
297
374
  ## License
298
375
 
299
376
  Apache-2.0