@pafi-dev/issuer 0.1.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 ADDED
@@ -0,0 +1,294 @@
1
+ # @pafi/issuer
2
+
3
+ Issuer backend boilerplate for the PAFI point token system. Wraps
4
+ `@pafi/core` with everything a partner needs to run the off-chain side
5
+ of the mint-and-cash-out flow: authentication, point ledger, policy
6
+ engine, EIP-712 issuer signing, relay submission, mint event indexing,
7
+ and framework-agnostic HTTP handlers.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pnpm add @pafi/issuer @pafi/core viem
13
+ ```
14
+
15
+ Peer deps: `viem ^2.0`, `@pafi/core` (workspace), `jose ^5` (transitive).
16
+
17
+ ## At a glance
18
+
19
+ ```ts
20
+ import { createIssuerService, PrivateKeySigner } from "@pafi/issuer";
21
+ import { createPublicClient, createWalletClient, http } from "viem";
22
+ import { privateKeyToAccount } from "viem/accounts";
23
+ import { base } from "viem/chains";
24
+
25
+ const provider = createPublicClient({ chain: base, transport: http(RPC_URL) });
26
+ const operatorWallet = createWalletClient({
27
+ account: privateKeyToAccount(OPERATOR_PK),
28
+ chain: base,
29
+ transport: http(RPC_URL),
30
+ });
31
+
32
+ const service = createIssuerService({
33
+ chainId: 8453,
34
+ pointTokenAddress: "0x5F...",
35
+ relayAddress: "0x92...",
36
+ contracts: { pointToken, relay, issuerRegistry, usdt },
37
+ provider,
38
+ operatorWallet,
39
+ auth: {
40
+ jwtSecret: process.env.JWT_SECRET!,
41
+ domain: "app.example.com",
42
+ },
43
+ // ⚠️ NOT for production — replace with a KMS-backed IIssuerSigner
44
+ signer: new PrivateKeySigner({ privateKey: ISSUER_PK, chain: base }),
45
+ });
46
+
47
+ // Wire `service.handlers` into Express / Fastify / Hono — see
48
+ // examples/express-issuer/ for a full runnable server.
49
+ ```
50
+
51
+ ## What you get
52
+
53
+ | Component | Default | Replace for production? |
54
+ | ----------------------- | --------------------- | ------------------------------------- |
55
+ | `IPointLedger` | `MemoryPointLedger` | ✅ Postgres / Prisma |
56
+ | `ISessionStore` | `MemorySessionStore` | ✅ Redis |
57
+ | `IPolicyEngine` | `DefaultPolicyEngine` | ➕ extend for KYC / caps / claim budget |
58
+ | `IIssuerSigner` | `PrivateKeySigner` | 🚨 **MUST replace** — KMS / HSM / MPC |
59
+ | `AuthService` | ✅ built-in | — |
60
+ | `RelayService` | ✅ built-in | — |
61
+ | `FeeManager` | ✅ built-in (opt-in) | injection-based — no DEX lock-in |
62
+ | `MintingGateway` | ✅ built-in | — |
63
+ | `PointIndexer` | ✅ built-in (polling) | — |
64
+ | `IssuerApiHandlers` | ✅ built-in | — |
65
+
66
+ See [`examples/adapters/`](./examples/adapters) for reference
67
+ implementations of the three interfaces you must replace.
68
+
69
+ ## The minting flow
70
+
71
+ The SDK's central orchestrator is `MintingGateway.processMintAndCashOut()`,
72
+ which runs the full 11-step flow documented in
73
+ [`docs/PAFI_ISSUER_SDK_SPEC.md`](../../../pafi-backend/docs/PAFI_ISSUER_SDK_SPEC.md):
74
+
75
+ ```
76
+ 1. Validate request fields (cheap rejects)
77
+ 2. Verify ReceiverConsent signature via @pafi/core
78
+ 3. Check off-chain balance via ledger
79
+ 4. Check locked requests via ledger
80
+ 5. Run policy engine (balance + cap + issuer rules)
81
+ 6. Lock the requested amount in the ledger
82
+ 7. Sign MintRequest as the issuer (via IIssuerSigner)
83
+ 8. Build Relay calldata (MintParams + SwapParams)
84
+ 9. Submit via RelayService (simulate → write → receipt)
85
+ 10. Return { txHash, lockId, block/gas }
86
+ 11. PointIndexer finalizes the ledger on the Mint event (out of band)
87
+ ```
88
+
89
+ **The gateway never deducts the balance directly.** Deduction is
90
+ driven by the `PointIndexer` when it observes the on-chain Mint event.
91
+ That makes the system crash-safe: if the gateway dies between
92
+ broadcast and receipt, the indexer still finalizes the ledger the next
93
+ time it polls.
94
+
95
+ ### Lock release semantics
96
+
97
+ Every `MintingGatewayError` carries a `safeToRetry: boolean` flag that
98
+ tells the API layer whether the underlying ledger lock was released:
99
+
100
+ | Error code | Lock released? | `safeToRetry` |
101
+ | --------------------------- | -------------- | ------------- |
102
+ | `INVALID_REQUEST` | never locked | `true` |
103
+ | `INVALID_CONSENT_SIGNATURE` | never locked | `true` |
104
+ | `CONSENT_EXPIRED` | never locked | `true` |
105
+ | `POLICY_REJECTED` | never locked | `true` |
106
+ | `INSUFFICIENT_BALANCE` | never locked | `true` |
107
+ | `SIGNER_FAILED` | ✅ released | `true` |
108
+ | `RELAY_SIMULATION_FAILED` | ✅ released | `true` |
109
+ | `RELAY_SUBMIT_FAILED` | ✅ released | `true` |
110
+ | `RELAY_REVERTED` | 🔒 kept | `false` |
111
+ | `RELAY_TIMEOUT` | 🔒 kept | `false` |
112
+
113
+ `RELAY_REVERTED` and `RELAY_TIMEOUT` keep the lock because the tx may
114
+ still be in the mempool or already mined — releasing would enable a
115
+ double-spend on retry. Your API should surface `409 Conflict` in these
116
+ cases and require manual reconciliation.
117
+
118
+ ## Authentication
119
+
120
+ Wallet-based login uses EIP-4361 (Sign-In with Ethereum):
121
+
122
+ ```
123
+ Frontend Issuer backend
124
+ ──────── ──────────────
125
+ 1. GET /auth/nonce → authService.getNonce()
126
+ ← { nonce }
127
+ 2. Build EIP-4361 message with
128
+ nonce (via PafiSDK or
129
+ @pafi/core createLoginMessage)
130
+ 3. Sign with wallet
131
+ 4. POST /auth/login → authService.login(message, sig)
132
+ { message, signature } → parse → verify → consume nonce
133
+ → create session → issue JWT
134
+ ← { token, userAddress, expiresAt }
135
+ 5. Authorization: Bearer <jwt> → authenticateRequest()
136
+ → verify JWT + session
137
+ → pass { userAddress, chainId } to handlers
138
+ ```
139
+
140
+ Key property: a failed signature does **not** burn the nonce. Users can
141
+ retry with the correct wallet on the same nonce without hitting the
142
+ backend again.
143
+
144
+ ## HTTP endpoints
145
+
146
+ The handlers are framework-agnostic — `async` functions that take plain
147
+ inputs and return plain outputs. Wrap them in Express / Fastify / Hono /
148
+ whatever you use. See `examples/express-issuer/server.ts` for the full
149
+ wiring.
150
+
151
+ | Method | Path | Auth | Handler |
152
+ | ------ | ---------------- | ---- | -------------------- |
153
+ | GET | /auth/nonce | no | `handleGetNonce` |
154
+ | POST | /auth/login | no | `handleLogin` |
155
+ | GET | /config | no | `handleConfig` |
156
+ | GET | /gas-fee | no | `handleGasFee` |
157
+ | POST | /auth/logout | yes | `handleLogout` |
158
+ | GET | /pools | yes | `handlePools` |
159
+ | GET | /user | yes | `handleUser` |
160
+ | POST | /claim-and-swap | yes | `handleClaimAndSwap` |
161
+
162
+ ## Security notes
163
+
164
+ - 🚨 **Never deploy `PrivateKeySigner` to production.** The private key lives in process memory and is trivially extractable from a compromised host. Use `examples/adapters/kms-signer.ts` as a starting point for a KMS/HSM integration.
165
+ - 🔑 **`jwtSecret` must be at least 16 characters (32+ recommended) and rotated periodically.** Sessions survive a rotation because the JWT lookup cross-checks the session store — but existing tokens signed with the old secret will fail `verifyToken`, forcing users to re-login.
166
+ - 🕐 **The default `MemorySessionStore` does not survive restarts.** Nonces and sessions are both lost. Use `examples/adapters/redis-session-store.ts` for anything past local development.
167
+ - 🔒 **The `MintingGatewayError.safeToRetry` flag is load-bearing.** Do not ignore it in your API layer — surfacing `RELAY_REVERTED` as a retryable 400 will cause double-spend the moment a tx lands after the first response.
168
+
169
+ ## Tests
170
+
171
+ The package ships with 142 unit tests covering every collaborator + the
172
+ full gateway flow through mocked providers. Run them with:
173
+
174
+ ```bash
175
+ pnpm --filter @pafi/issuer test
176
+ ```
177
+
178
+ No network or on-chain state is required — all tests are hermetic.
179
+
180
+ ## Exports
181
+
182
+ ```ts
183
+ import {
184
+ // Factory (recommended entry point)
185
+ createIssuerService,
186
+ type IssuerService,
187
+ type IssuerServiceConfig,
188
+
189
+ // Ledger
190
+ MemoryPointLedger,
191
+ type IPointLedger,
192
+ type LockedMintRequest,
193
+ type MintingStatus,
194
+
195
+ // Policy
196
+ DefaultPolicyEngine,
197
+ type IPolicyEngine,
198
+ type PolicyDecision,
199
+ type PolicyEvalRequest,
200
+
201
+ // Signer
202
+ PrivateKeySigner, // DEV ONLY
203
+ type IIssuerSigner,
204
+
205
+ // Auth
206
+ AuthService,
207
+ AuthError,
208
+ type AuthErrorCode,
209
+ type AuthContext,
210
+ MemorySessionStore,
211
+ type ISessionStore,
212
+ type Session,
213
+ NonceManager,
214
+ authenticateRequest,
215
+
216
+ // Relay + fees
217
+ RelayService,
218
+ RelayError,
219
+ type RelayErrorCode,
220
+ type SubmitMintAndSwapParams,
221
+ type RelayResult,
222
+ type OperatorWalletLike,
223
+ FeeManager,
224
+ type FeeManagerConfig,
225
+
226
+ // Gateway
227
+ MintingGateway,
228
+ MintingGatewayError,
229
+ type MintingGatewayErrorCode,
230
+ type MintAndCashOutRequest,
231
+ type MintAndCashOutResponse,
232
+ encodeExtData,
233
+
234
+ // Indexer
235
+ PointIndexer,
236
+ type PointIndexerConfig,
237
+ type MintEvent,
238
+ type IIndexerCursorStore,
239
+ InMemoryCursorStore,
240
+
241
+ // API handlers + HTTP contract (source of truth — FE imports these type-only)
242
+ IssuerApiHandlers,
243
+ type IssuerApiHandlersConfig,
244
+ type ApiConfigResponse,
245
+ type ApiNonceResponse,
246
+ type ApiLoginRequest,
247
+ type ApiLoginResponse,
248
+ type ApiGasFeeResponse,
249
+ type ApiPoolsRequest,
250
+ type ApiPoolsResponse,
251
+ type ApiUserRequest,
252
+ type ApiUserResponse,
253
+ type ApiClaimAndSwapRequest,
254
+ type ApiClaimAndSwapResponse,
255
+ type PoolsProvider,
256
+ } from "@pafi/issuer";
257
+ ```
258
+
259
+ ### HTTP contract ownership
260
+
261
+ `@pafi/issuer` is the **single source of truth** for the HTTP API contract
262
+ between the issuer backend and any frontend / mobile / SDK consumer. The
263
+ request/response types above are defined once here, then imported
264
+ **type-only** by frontend code so browser bundles never pull in server
265
+ code (`jose`, `node:crypto`, indexer polling loops, etc):
266
+
267
+ ```ts
268
+ // Frontend — type-only import, erased at build time
269
+ import type {
270
+ ApiLoginRequest,
271
+ ApiClaimAndSwapRequest,
272
+ ApiUserResponse,
273
+ } from "@pafi/issuer";
274
+
275
+ // Build your own fetch call with full type safety
276
+ const res = await fetch(`${API_URL}/claim-and-swap`, {
277
+ method: "POST",
278
+ headers: {
279
+ "Content-Type": "application/json",
280
+ Authorization: `Bearer ${token}`,
281
+ },
282
+ body: JSON.stringify(request satisfies ApiClaimAndSwapRequest),
283
+ });
284
+ const body = (await res.json()) as ApiClaimAndSwapResponse;
285
+ ```
286
+
287
+ **`@pafi/core` deliberately does NOT ship an HTTP client** — it handles
288
+ only cryptography (EIP-712 / EIP-4361), contract reads, and V4 quoting.
289
+ That's what keeps the frontend bundle small and keeps the protocol
290
+ contract in exactly one place.
291
+
292
+ ## License
293
+
294
+ UNLICENSED — internal use by PAFI issuers.