@pafi-dev/issuer 0.6.0 → 0.6.1

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.
Files changed (2) hide show
  1. package/README.md +219 -435
  2. package/package.json +2 -2
package/README.md CHANGED
@@ -3,11 +3,15 @@
3
3
  [![npm](https://img.shields.io/npm/v/@pafi-dev/issuer)](https://www.npmjs.com/package/@pafi-dev/issuer)
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
- Issuer backend SDK for the PAFI point token system. Wraps `@pafi-dev/core` with everything
7
- a partner needs to run the off-chain side of the cashout flow: SIWE authentication, point
8
- ledger, policy engine, EIP-712 issuer signing, relay submission, and mint/burn event indexing.
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.
9
10
 
10
- > **Server-only.** This package pulls in `jose` and `node:crypto`. Do not bundle into a browser app.
11
+ **Server-only.** Pulls in signer wallets, HTTP clients, ledger
12
+ interfaces. Don't bundle into a browser app — use
13
+ `@pafi-dev/trading` (FE swap/quote) or `@pafi-dev/core` (primitives)
14
+ instead.
11
15
 
12
16
  ---
13
17
 
@@ -15,501 +19,281 @@ ledger, policy engine, EIP-712 issuer signing, relay submission, and mint/burn e
15
19
 
16
20
  - Node.js >= 18
17
21
  - TypeScript >= 5.0
18
- - `viem` ^2.0.0 and `@pafi-dev/core` ^0.5.16 (peer dependencies)
19
-
20
- > **Latest:** `0.5.31` — `RelayService.prepareMint` / `prepareBurn`
21
- > now auto-quote the operator fee and auto-resolve the PAFI fee
22
- > recipient. Drop `feeAmount` / `feeRecipient` from your call sites;
23
- > SDK reads gas price + Chainlink + V4 subgraph internally. See
24
- > [Changelog](#changelog).
22
+ - `viem` ^2.0.0 (peer)
23
+ - `@pafi-dev/core` (peer — re-exported types)
25
24
 
26
25
  ---
27
26
 
28
27
  ## Installation
29
28
 
30
29
  ```bash
31
- npm install @pafi-dev/issuer @pafi-dev/core viem
32
- # or
33
30
  pnpm add @pafi-dev/issuer @pafi-dev/core viem
31
+ # Optional ledger backend:
32
+ pnpm add @pafi-dev/issuer-postgres typeorm pg
34
33
  ```
35
34
 
36
35
  ---
37
36
 
38
- ## Modules
39
-
40
- | Module | What it provides |
41
- |---|---|
42
- | `relay/` | `RelayService` — build unsigned UserOps for mint + burn |
43
- | `auth/` | `AuthService` — SIWE verification + JWT issuance; `authenticateRequest` middleware |
44
- | `ledger/` | `IPointLedger` interface — implement against your own database |
45
- | `policy/` | `IPolicyEngine`, `DefaultPolicyEngine` — off-chain balance gate |
46
- | `indexer/` | `PointIndexer` (mint events) + `BurnIndexer` (burn events) |
47
- | `balance/` | `BalanceAggregator` — merge off-chain ledger + on-chain `balanceOf` |
48
- | `api/` | `IssuerApiHandlers` — framework-agnostic HTTP handlers |
49
- | `pafi-backend/` | `PafiBackendClient` — HTTP client for PAFI paymaster backend |
50
-
51
- ---
52
-
53
- ## What you must bring
37
+ ## What's in this package
54
38
 
55
- The SDK deliberately does not ship production implementations for:
39
+ ```
40
+ @pafi-dev/issuer
41
+ ├── handlers — flow handlers (PTClaimHandler, PTRedeemHandler,
42
+ │ PerpDepositHandler)
43
+ ├── api/IssuerApiAdapter
44
+ │ — single class, every endpoint is one line in
45
+ │ your controller
46
+ ├── api/handleMobilePrepare/Submit
47
+ │ — mobile claim/redeem orchestrators
48
+ ├── api/handleClaimStatus/handleRedeemStatus
49
+ │ — polling helpers w/ bundler-receipt fallback
50
+ ├── api/handleDelegateSubmit
51
+ │ — EIP-7702 delegation submit (empty-batch + auth)
52
+ ├── api/createSdkErrorMapper
53
+ │ — framework-agnostic error → HTTP status mapper
54
+ ├── ledger — IPointLedger interface + InMemoryCursorStore
55
+ ├── userop-store — IPendingUserOpStore + MemoryPendingUserOpStore
56
+ ├── pafi-backend — sponsor-relayer client + relay/paymaster helpers
57
+ ├── auth — ISessionStore, AuthService (SIWE), MemorySessionStore
58
+ ├── relay — RelayService, FeeManager
59
+ ├── pools — createSubgraphPoolsProvider, NativePtQuoter
60
+ ├── policy — IPolicyEngine + DefaultPolicyEngine
61
+ ├── balance — BalanceAggregator (off-chain + on-chain)
62
+ ├── indexer — PointIndexer, BurnIndexer (singleton workers)
63
+ ├── issuer-state — IssuerStateValidator (registry + cap pre-check)
64
+ └── errors — PafiSdkError base class for typed errors
65
+ ```
56
66
 
57
- | What | Why you own it |
58
- |---|---|
59
- | **`IPointLedger`** | Your database, your schema, your row-level locking strategy |
60
- | **`ISessionStore`** | Must be shared across pods — use Redis or Postgres |
61
- | **Signing wallet** | Private key must never touch server memory — use KMS |
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.
62
71
 
63
72
  ---
64
73
 
65
- ## Quick start
66
-
67
- ```ts
68
- import { createIssuerService } from "@pafi-dev/issuer";
69
- import { getContractAddresses } from "@pafi-dev/core";
70
- import { createPublicClient, createWalletClient, http } from "viem";
71
- import { base } from "viem/chains";
72
- import { privateKeyToAccount } from "viem/accounts";
73
-
74
- const addrs = getContractAddresses(8453);
75
-
76
- // Production: replace with a KMS-backed WalletClient (see "Production signing" below)
77
- const issuerSignerWallet = createWalletClient({
78
- account: privateKeyToAccount(process.env.MINTER_PRIVATE_KEY as `0x${string}`),
79
- chain: base,
80
- transport: http(process.env.RPC_URL),
81
- });
82
-
83
- const service = createIssuerService({
84
- chainId: 8453,
85
- pointTokenAddresses: [addrs.pointToken],
86
- contracts: {
87
- relay: "0x92327F5c9383796Dd46D43E0995cc938038A98c4",
88
- issuerRegistry: addrs.issuerRegistry,
89
- usdt: addrs.usdt,
90
- batchExecutor: addrs.batchExecutor,
91
- },
92
- provider: createPublicClient({ chain: base, transport: http(process.env.RPC_URL) }),
93
- auth: {
94
- jwtSecret: process.env.JWT_SECRET!,
95
- domain: "app.example.com",
96
- },
97
- ledger: new YourPostgresLedger(), // implements IPointLedger
98
- sessionStore: new YourRedisSessionStore(), // implements ISessionStore
99
- claim: {
100
- issuerSignerWallet,
101
- batchExecutorAddress: addrs.batchExecutor,
102
- },
103
- });
74
+ ## Architecture
104
75
 
105
- // Mount into your framework (Express, Fastify, NestJS, Hono...)
106
- app.get("/auth/nonce", () => service.handlers.handleGetNonce());
107
- app.post("/auth/login", (req) => service.handlers.handleLogin(req.body));
108
- app.get("/user", (req) => service.handlers.handleUser(req.user, req.query));
109
- app.post("/claim", (req) => service.handlers.handleClaim(req.user, req.body));
110
76
  ```
111
-
112
- ---
113
-
114
- ## Implementing IPointLedger
115
-
116
- Every issuer provides their own database-backed implementation:
117
-
118
- ```ts
119
- import type { IPointLedger, LockedMintRequest, MintingStatus } from "@pafi-dev/issuer";
120
- import type { Address, Hex } from "viem";
121
-
122
- export class PostgresPointLedger implements IPointLedger {
123
- async getBalance(user: Address, tokenAddress?: Address): Promise<bigint> { ... }
124
-
125
- // Must use a row-level lock (SELECT ... FOR UPDATE) to prevent
126
- // concurrent pods from reading the same available balance and double-spending.
127
- async lockForMinting(user: Address, amount: bigint, durationMs: number, tokenAddress?: Address): Promise<string> { ... }
128
-
129
- async releaseLock(lockId: string): Promise<void> { ... }
130
- async deductBalance(user: Address, amount: bigint, txHash: Hex, tokenAddress?: Address): Promise<void> { ... }
131
- async creditBalance(user: Address, amount: bigint, reason: string, tokenAddress?: Address): Promise<void> { ... }
132
- async getLockedRequests(user: Address, tokenAddress?: Address): Promise<LockedMintRequest[]> { ... }
133
- async updateMintStatus(lockId: string, status: MintingStatus, txHash?: Hex): Promise<void> { ... }
134
-
135
- // Burn flow: reserve → resolve when burn tx confirmed
136
- async reservePendingCredit(user: Address, amount: bigint, durationMs: number, tokenAddress?: Address): Promise<string> { ... }
137
- async resolveCreditByBurnTx(lockId: string, txHash: Hex): Promise<void> { ... }
138
- }
77
+ HTTP request (NestJS / Fastify / Express)
78
+
79
+ Auth guard ← issuer-specific (SIWE / Privy / NextAuth)
80
+
81
+ Controller ← thin: routing + DTO + auth context
82
+
83
+ IssuerApiAdapter ← @pafi-dev/issuer (orchestrates flows)
84
+
85
+ PTClaimHandler ← signs MintRequest, locks balance, builds UserOp
86
+ PTRedeemHandler ← signs BurnRequest, reserves credit, builds UserOp
87
+ PerpDepositHandler ← Orderly Vault via PAFI Relay
88
+
89
+ PostgresPointLedger ← @pafi-dev/issuer-postgres (TypeORM)
90
+ RelayService ← UserOp builders for mint/burn
91
+ PafiBackendClient ← sponsor-relayer proxy
139
92
  ```
140
93
 
141
94
  ---
142
95
 
143
- ## Production signing — KMS
144
-
145
- In production the `issuerSignerWallet` must be backed by a hardware key.
146
- The SDK accepts any viem `WalletClient` — wire it to AWS KMS via a custom account:
96
+ ## Quick start (NestJS)
147
97
 
148
- ```ts
149
- import { KMSClient } from "@aws-sdk/client-kms";
150
- import { createWalletClient, http } from "viem";
151
- import { base } from "viem/chains";
152
-
153
- // Custom viem account that delegates signTypedData to KMS.
154
- // Full DER→(r,s) conversion + v-byte recovery omitted for brevity.
155
- const kmsAccount = toAccount({
156
- address: process.env.KMS_MINTER_ADDRESS as `0x${string}`,
157
- async signTypedData(typedData) {
158
- const digest = hashTypedData(typedData);
159
- const { Signature } = await kms.send(new SignCommand({
160
- KeyId: process.env.KMS_MINTER_KEY_ID!,
161
- Message: digest,
162
- MessageType: "DIGEST",
163
- SigningAlgorithm: "ECDSA_SHA_256",
164
- }));
165
- return derToViem(Signature!); // convert DER → 65-byte viem hex signature
166
- },
167
- });
98
+ Minimal reference at `examples/nestjs-issuer/` — full working backend
99
+ with all 11 endpoints, ~940 LoC total.
168
100
 
169
- const issuerSignerWallet = createWalletClient({
170
- account: kmsAccount,
171
- chain: base,
172
- transport: http(process.env.RPC_URL),
173
- });
174
- ```
175
-
176
- ---
177
-
178
- ## Event indexers
179
-
180
- Indexers sync on-chain mint/burn events back to the off-chain ledger.
101
+ ### 1. Wire `IssuerApiAdapter`
181
102
 
182
103
  ```ts
183
- import { PointIndexer, BurnIndexer } from "@pafi-dev/issuer";
104
+ import { Provider } from "@nestjs/common";
105
+ import {
106
+ IssuerApiAdapter,
107
+ IssuerStateValidator,
108
+ PTClaimHandler,
109
+ PTRedeemHandler,
110
+ PerpDepositHandler,
111
+ MemoryPendingUserOpStore,
112
+ createSubgraphPoolsProvider,
113
+ type IssuerService,
114
+ } from "@pafi-dev/issuer";
115
+ import { PostgresPointLedger } from "@pafi-dev/issuer-postgres";
184
116
  import { getContractAddresses } from "@pafi-dev/core";
185
117
 
186
- const addrs = getContractAddresses(8453);
187
-
188
- // Mint indexer: Transfer(0x0 user) deducts off-chain locked balance
189
- const mintIndexer = new PointIndexer({
190
- provider: publicClient,
191
- pointTokenAddress: addrs.pointToken,
192
- ledger,
193
- cursorStore, // persists last-processed block; use Postgres or Redis
194
- fromBlock: 28_000_000n,
195
- confirmations: 2,
196
- pollIntervalMs: 3_000,
197
- });
198
-
199
- // Burn indexer: Transfer(user → 0x0) → resolves pending off-chain credit
200
- const burnIndexer = new BurnIndexer({
201
- provider: publicClient,
202
- pointTokenAddress: addrs.pointToken,
203
- ledger,
204
- cursorStore,
205
- matchLockId: async (evt) => {
206
- // Return the lockId from reservePendingCredit() that matches this burn.
207
- // Typically a DB lookup by (from, amount, status=PENDING).
208
- return db.findPendingCreditLockId(evt.from, evt.amount);
209
- },
210
- });
211
-
212
- mintIndexer.start();
213
- burnIndexer.start();
214
- ```
215
-
216
- > **Indexers must be singletons.** Run exactly one instance per token per deployment.
217
- > Use a Kubernetes `Deployment` with `replicas: 1`.
218
-
219
- ---
220
-
221
- ## PafiBackendClient
222
-
223
- HTTP client for requesting paymaster sponsorship from the PAFI backend:
224
-
225
- ```ts
226
- import { PafiBackendClient } from "@pafi-dev/issuer";
227
-
228
- const pafiClient = new PafiBackendClient({
229
- url: "https://api-dev.pacificfinance.org/api/sponsor",
230
- issuerId: "gg56",
231
- apiKey: process.env.PAFI_API_KEY!,
232
- retry: { maxAttempts: 3, maxRetryAfterMs: 5_000 },
233
- });
234
-
235
- const sponsorship = await pafiClient.requestSponsorship({
236
- chainId: 8453,
237
- scenario: "mint",
238
- userOp: {
239
- sender: userAddress,
240
- callData: userOp.callData,
241
- // ...gas fields
118
+ export const issuerApiAdapterProvider: Provider = {
119
+ provide: ISSUER_API_ADAPTER,
120
+ useFactory: (issuerService, provider, walletClient, dataSource) => {
121
+ const ledger = new PostgresPointLedger(dataSource);
122
+ const { issuerRegistry, batchExecutor } = getContractAddresses(8453);
123
+
124
+ return new IssuerApiAdapter({
125
+ issuerService,
126
+ ledger,
127
+ provider,
128
+ issuerSignerWallet: walletClient,
129
+ pafiIssuerId: process.env.PAFI_ISSUER_ID,
130
+
131
+ ptClaimHandler: new PTClaimHandler({
132
+ ledger, relayService: issuerService.relay, provider,
133
+ issuerSignerWallet: walletClient,
134
+ pointTokenDomainName: "POINT",
135
+ feeService: issuerService.fee,
136
+ issuerStateValidator: new IssuerStateValidator(provider, issuerRegistry),
137
+ }),
138
+
139
+ ptRedeemHandler: new PTRedeemHandler({
140
+ ledger, relayService: issuerService.relay, provider,
141
+ pointTokenAddress: POINT_TOKEN,
142
+ batchExecutorAddress: batchExecutor,
143
+ chainId: 8453,
144
+ domain: { name: "POINT", verifyingContract: POINT_TOKEN },
145
+ burnerSignerWallet: walletClient,
146
+ feeService: issuerService.fee,
147
+ }),
148
+
149
+ perpHandler: new PerpDepositHandler({
150
+ provider, feeService: issuerService.fee, pointTokenAddress: POINT_TOKEN,
151
+ }),
152
+
153
+ pendingUserOpStore: new MemoryPendingUserOpStore(),
154
+ pafiBackendClient, // optional — required for mobile submit / sponsor-relayer
155
+ });
242
156
  },
243
- target: {
244
- contractAddress: addrs.pointToken,
245
- functionSelector: "0x...",
246
- },
247
- });
248
-
249
- // Paymaster fields
250
- // sponsorship.paymaster
251
- // sponsorship.paymasterData
252
- // sponsorship.paymasterVerificationGasLimit
253
- // sponsorship.paymasterPostOpGasLimit
254
- // Pimlico's re-estimated values — MUST overwrite the userOp's matching
255
- // fields BEFORE computing the userOpHash, or both AA24 (sender sig) and
256
- // AA34 (paymaster sig) fire on the bundler. See "Critical: apply gas
257
- // overrides" below.
258
- // sponsorship.callGasLimit?
259
- // sponsorship.verificationGasLimit?
260
- // sponsorship.preVerificationGas?
261
- // sponsorship.maxFeePerGas? // bundler-required floor
262
- // sponsorship.maxPriorityFeePerGas?
263
- // sponsorship.expiresAt
157
+ inject: [...],
158
+ };
264
159
  ```
265
160
 
266
- ### Critical: apply gas overrides before computing the userOpHash
161
+ Skip handlers you don't need adapter throws clear "<handler> not
162
+ wired" if the corresponding method is called.
267
163
 
268
- Pimlico's `pm_sponsorUserOperation` does two things the SDK didn't
269
- previously echo back:
270
-
271
- 1. **Re-estimates** `callGasLimit` / `verificationGasLimit` /
272
- `preVerificationGas`. The paymaster signature signs over these new
273
- values.
274
- 2. **Quotes the bundler-required gas price** — `eth_feeHistory` on Base
275
- regularly underestimates the bundler floor by 10–15 %, producing
276
- `maxFeePerGas must be at least …` rejections.
277
-
278
- Both sets of values are now returned on `SponsorshipResponse`. Apply
279
- them to the userOp **before** calling `computeUserOpHash`:
280
-
281
- ```ts
282
- function applyOverrides<T extends Record<string, unknown>>(
283
- userOp: T,
284
- fields: Awaited<ReturnType<typeof pafiClient.requestSponsorship>> | undefined,
285
- ): T {
286
- if (!fields) return userOp;
287
- const merged: Record<string, unknown> = { ...userOp };
288
- for (const [k, v] of Object.entries(fields)) if (v !== undefined) merged[k] = v;
289
- return merged as T;
290
- }
291
-
292
- const fields = await pafiClient.requestSponsorship({ /* ... */ });
293
- const final = applyOverrides(userOp, fields);
294
- const userOpHash = computeUserOpHash(final, chainId); // matches what the bundler will hash
295
- ```
296
-
297
- ### Bundler receipt fallback for status polling
298
-
299
- `PointIndexer.deductBalance` matches `(user, token, amount, oldest
300
- PENDING)`. When several PENDING locks share the same amount, the
301
- indexer can resolve a *different* lock than the one the user is
302
- currently polling — symptom: `/claim/status` returns PENDING forever
303
- even though the on-chain mint succeeded.
304
-
305
- `PafiBackendClient.getUserOpReceipt(userOpHash)` proxies
306
- `eth_getUserOperationReceipt` through PAFI's authenticated `/bundler/receipt`
307
- endpoint, letting status handlers short-circuit the indexer:
164
+ ### 2. Slim controller
308
165
 
309
166
  ```ts
310
- import { PafiBackendClient } from "@pafi-dev/issuer";
311
-
312
- // Inside POST /claim/submit, persist the bundler-returned userOpHash on
313
- // the lock (add a `user_op_hash` column to your locked_mint_requests).
314
- await ledger.bindMintUserOpHash(lockId, result.userOpHash);
315
-
316
- // Inside GET /claim/status/:lockId
317
- const lock = await ledger.getMintLock(lockId);
318
- if (lock.status === "PENDING" && lock.userOpHash) {
319
- const receipt = await pafiClient.getUserOpReceipt(lock.userOpHash);
320
- if (receipt) {
321
- const status = receipt.success ? "MINTED" : "FAILED";
322
- await ledger.updateMintStatus(lock.id, status, receipt.txHash);
323
- return { ...lock, status, txHash: receipt.txHash };
167
+ @Controller()
168
+ export class IssuerController {
169
+ constructor(@Inject(ISSUER_API_ADAPTER) private api: IssuerApiAdapter) {}
170
+
171
+ @Post("claim/prepare")
172
+ @UseGuards(JwtGuard)
173
+ async claimPrepare(@User() user: AuthContext, @Body() body) {
174
+ const [aaNonce, mintRequestNonce] = await Promise.all([
175
+ fetchAaNonce(this.provider, user.userAddress),
176
+ fetchMintRequestNonce(this.provider, body.pointTokenAddress, user.userAddress),
177
+ ]);
178
+ return wrap(() => this.api.claimPrepare({
179
+ authenticatedAddress: user.userAddress,
180
+ chainId: body.chainId,
181
+ pointTokenAddress: body.pointTokenAddress,
182
+ amount: BigInt(body.amount),
183
+ aaNonce, mintRequestNonce,
184
+ }));
324
185
  }
186
+ // ... other endpoints similarly thin
325
187
  }
326
- return lock; // bundler hasn't seen it yet — keep polling
327
188
  ```
328
189
 
329
- Returns `null` when the bundler hasn't confirmed yet (still in mempool /
330
- not bundled). The indexer continues to do off-chain accounting in the
331
- background; the receipt fallback only affects *user-visible status*,
332
- not balance correctness.
333
-
334
- ---
335
-
336
- ## Error handling
337
-
338
- ### AuthError
339
-
340
- Thrown by `AuthService` and `authenticateRequest` middleware:
190
+ ### 3. Wire error mapper
341
191
 
342
192
  ```ts
343
- import { AuthError, type AuthErrorCode } from "@pafi-dev/issuer";
344
-
345
- // AuthErrorCode:
346
- // "INVALID_MESSAGE" | "INVALID_SIGNATURE" | "NONCE_MISMATCH"
347
- // "NONCE_EXPIRED" | "TOKEN_EXPIRED" | "TOKEN_INVALID"
348
- // "SESSION_NOT_FOUND" | "DOMAIN_MISMATCH" | "CHAIN_MISMATCH"
349
-
350
- try {
351
- const user = await authService.login(message, signature);
352
- } catch (err) {
353
- if (err instanceof AuthError) {
354
- console.log(err.code); // AuthErrorCode
355
- }
356
- }
357
- ```
358
-
359
- ### PafiBackendError
360
-
361
- Thrown by `PafiBackendClient`:
193
+ 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),
204
+ });
362
205
 
363
- ```ts
364
- import { PafiBackendError, type PafiBackendErrorCode } from "@pafi-dev/issuer";
365
-
366
- // PafiBackendErrorCode:
367
- // "RATE_LIMIT_EXCEEDED" | "RATE_LIMIT_EXCEEDED_DAILY" | "RATE_LIMIT_EXCEEDED_PER_USER"
368
- // "RATE_LIMITER_UNAVAILABLE"
369
- // "INTENT_REJECTED" | "MINT_CAP_EXCEEDED" | "ISSUER_INACTIVE"
370
- // "ISSUER_UNAUTHORIZED" | "USER_UNAUTHORIZED"
371
- // "PAYMASTER_UNAVAILABLE" | "TARGET_NOT_ALLOWLISTED"
372
- // "BAD_REQUEST" | "INTERNAL_ERROR" | "TIMEOUT" | "NETWORK_ERROR"
373
-
374
- try {
375
- await pafiClient.requestSponsorship(request);
376
- } catch (err) {
377
- if (err instanceof PafiBackendError) {
378
- if (err.code === "RATE_LIMIT_EXCEEDED") {
379
- // err.retryAfter — seconds to wait before retrying
380
- }
381
- }
206
+ async function wrap<T>(fn: () => Promise<T>): Promise<T> {
207
+ try { return await fn(); }
208
+ catch (err) { sdkErrorMapper(err); }
382
209
  }
383
210
  ```
384
211
 
385
- ---
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.
386
215
 
387
- ## Changelog
388
-
389
- ### 0.5.31
390
-
391
- `RelayService.prepareMint` / `prepareBurn` now auto-quote the operator
392
- fee + auto-resolve the PAFI fee recipient when the service is
393
- constructed with `provider + chainId`. `createIssuerService` wires
394
- this automatically — issuer integrations don't need to change
395
- anything to pick it up.
396
-
397
- **API changes** (additive, backwards-compatible):
398
-
399
- - `RelayService` constructor accepts an optional `RelayServiceConfig`
400
- with `provider` + `chainId`. When set, callers can drop `feeAmount`
401
- / `feeRecipient` from `prepareMint` / `prepareBurn` calls — the
402
- service runs `quoteOperatorFeePt` (Chainlink ETH/USD + V4 subgraph
403
- PT/USDT spot price) internally and pulls the canonical recipient
404
- from `getContractAddresses(chainId).pafiFeeRecipient`.
405
- - `prepareBurn` is now `async` (was sync) — the auto-quote requires
406
- RPC + subgraph reads. Callers must `await`. `PTRedeemHandler`
407
- already does this.
408
- - `feeAmount: 0n` explicit means "no fee transfer" (force unsponsored
409
- fallback variant). `undefined` means "auto-quote".
410
- - Existing callers that pass `feeAmount + feeRecipient` keep working
411
- — those values override the auto-resolve.
412
-
413
- **Migration**: in your controller, drop the manual fee fetch when
414
- calling `relayService.prepareMint`:
415
-
416
- ```diff
417
- - const { pafiFeeRecipient: feeRecipient } = getContractAddresses(chainId);
418
- - const feeAmount = await issuerService.fee.estimateGasFee();
419
- const userOp = await relayService.prepareMint({
420
- ...,
421
- - feeAmount,
422
- - feeRecipient,
423
- });
424
- ```
216
+ ---
425
217
 
426
- ### 0.5.28
218
+ ## `IssuerApiAdapter` methods
219
+
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.
427
238
 
428
- `PafiBackendClient.getUserOpReceipt(userOpHash)` added.
239
+ ---
429
240
 
430
- **Why.** When several PENDING mint/redeem locks share the same amount,
431
- `PointIndexer` / `BurnIndexer` resolve them by `(user, token, amount,
432
- oldest PENDING)` — the userOp's actual bundler hash isn't part of the
433
- match. A lock the user is *currently polling* can be left PENDING while
434
- a *sibling* lock gets the on-chain `tx_hash`, so `/claim/status`
435
- returns PENDING forever even though the mint succeeded.
241
+ ## Mobile prepare/submit flow
436
242
 
437
- The new method calls PAFI's authenticated bundler-receipt proxy and
438
- returns `{ success, txHash, blockNumber } | null`. Status handlers can
439
- look up the user's specific userOpHash and bypass the indexer race
440
- entirely:
243
+ Mobile clients (Privy expo) can't `useSign7702Authorization` they
244
+ sign just the `userOpHash` via `personal_sign`. The adapter handles
245
+ the orchestration:
441
246
 
442
247
  ```ts
443
- const receipt = await pafiClient.getUserOpReceipt(lock.userOpHash);
444
- if (receipt?.success) markMinted(lock.id, receipt.txHash);
445
- ```
446
-
447
- Pair this with a `user_op_hash` column on your locks table that you set
448
- inside `/claim/submit` from the value returned by `relayUserOperation`.
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, ... }
449
251
 
450
- ### 0.5.27
252
+ // 2. mobile signs prepared.userOpHash via Privy personal_sign
451
253
 
452
- `SponsorshipResponse` now carries Pimlico's re-estimated gas + bundler
453
- gas price.
254
+ // 3. mobile sends POST /claim/submit backend runs:
255
+ const result = await api.claimSubmit({ authenticatedAddress, lockId, signature, variant: "sponsored" | "fallback" });
256
+ // returns: { userOpHash } — bundler hash for status polling
257
+ ```
454
258
 
455
- **Why this fixes AA34 / AA24.** `pm_sponsorUserOperation` re-estimates
456
- `callGasLimit` / `verificationGasLimit` / `preVerificationGas` AND
457
- quotes the bundler-required `maxFeePerGas` / `maxPriorityFeePerGas`. The
458
- paymaster signature is computed *over those new values*, not the values
459
- the SDK originally proposed. If the caller submits the userOp with the
460
- *original* gas fields:
259
+ `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.
461
262
 
462
- - The bundler hashes the on-chain UserOp with the original values
463
- - The paymaster signature recovers a different signer → `AA34
464
- signature error`
465
- - The user signature, computed over a different `userOpHash`, also
466
- fails → `AA24 signature error` (whichever fires first depends on the
467
- validation order)
263
+ ---
468
264
 
469
- The response now exposes:
265
+ ## Status polling
470
266
 
471
267
  ```ts
472
- interface SponsorshipResponse {
473
- paymaster: Address;
474
- paymasterData: Hex;
475
- paymasterVerificationGasLimit: bigint;
476
- paymasterPostOpGasLimit: bigint;
477
- callGasLimit?: bigint; // NEW — Pimlico re-estimate
478
- verificationGasLimit?: bigint; // NEW
479
- preVerificationGas?: bigint; // NEW
480
- maxFeePerGas?: bigint; // NEW — bundler-required floor
481
- maxPriorityFeePerGas?: bigint; // NEW
482
- expiresAt: number;
483
- }
268
+ // GET /claim/status/:lockId
269
+ const status = await api.claimStatus(user.userAddress, lockId);
270
+ // { lockId, status: 'PENDING' | 'MINTED' | 'EXPIRED' | 'FAILED', txHash, ... }
484
271
  ```
485
272
 
486
- **Callers must merge these (skipping `undefined`) into the userOp
487
- before computing `userOpHash`**see the
488
- "Critical: apply gas overrides" section above.
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).
489
277
 
490
- ### 0.5.26
278
+ ---
491
279
 
492
- `@pafi-dev/core` peer dependency bumped to `0.5.16` to pull in the v0.8
493
- EIP-712 userOpHash + `buildUserOpTypedData()` helpers. This is what
494
- makes the EIP-7702 mobile flow validate cleanly on Pimlico's
495
- `Simple7702Account` (no AA24).
280
+ ## Error classes
496
281
 
497
- ### 0.4.0
498
- - `PafiBackendClient` added — HTTP client for PAFI paymaster backend with retry logic and bigint serialization
499
- - `SponsorAuth` support — issuer signs EIP-712 authorization for FE to request paymaster sponsorship
500
- - `PAYMASTER_UNAVAILABLE`, `TARGET_NOT_ALLOWLISTED` added to `PafiBackendErrorCode`
282
+ All SDK errors inherit `PafiSdkError`. Subclasses + their HTTP status:
501
283
 
502
- ### 0.3.0-beta.10
503
- - `MemoryPointLedger` removed from public exports — each issuer must implement `IPointLedger`
504
- - `PrivateKeySigner` / `IIssuerSigner` removed SDK accepts viem `WalletClient` directly
505
- - `ledger` is now a required field in `IssuerServiceConfig`
506
- - `handleClaim` added to `IssuerApiHandlers`
284
+ | Error | `httpStatus` | `safeToRetry` |
285
+ | --- | --- | --- |
286
+ | `PendingUserOpNotFoundError` | `not_found` | false |
287
+ | `PendingUserOpForbiddenError` | `forbidden` | false |
288
+ | `LockNotFoundError` | `not_found` | false |
289
+ | `IssuerStateError` | `unprocessable` | true if `MINT_CAP_EXCEEDED` |
290
+ | `PerpDepositError` | `unprocessable` | true if `RELAY_FEE_EXCEEDS_AMOUNT` |
291
+ | `PTClaimError`, `PTRedeemError` | `unprocessable` | false |
292
+ | `BundlerNotConfiguredError` | `service_unavailable` | false |
293
+ | `BundlerRejectedError` | `unprocessable` | false |
507
294
 
508
- ### 0.3.0-beta.9
509
- - `PTRedeemHandler` enforces on-chain balance check before signing `BurnRequest`
295
+ ---
510
296
 
511
- ### 0.3.0-beta.8
512
- - `BalanceAggregator`, `BurnIndexer`, `PTRedeemHandler`, `TopUpRedemptionHandler`
297
+ ## License
513
298
 
514
- ### 0.3.0-alpha.0
515
- - Initial `RelayService`, `PointIndexer`, `AuthService`, `IssuerApiHandlers`
299
+ Apache-2.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pafi-dev/issuer",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "Issuer backend API and services for the PAFI point token system",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -23,7 +23,7 @@
23
23
  ],
24
24
  "dependencies": {
25
25
  "jose": "^5.9.0",
26
- "@pafi-dev/core": "0.6.0"
26
+ "@pafi-dev/core": "0.6.2"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "viem": "^2.0.0"