@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 +294 -0
- package/dist/index.cjs +1759 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1303 -0
- package/dist/index.d.ts +1303 -0
- package/dist/index.js +1728 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -0
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.
|