@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.
- package/README.md +219 -435
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@pafi-dev/issuer)
|
|
4
4
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
244
|
-
|
|
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
|
-
|
|
161
|
+
Skip handlers you don't need — adapter throws clear "<handler> not
|
|
162
|
+
wired" if the corresponding method is called.
|
|
267
163
|
|
|
268
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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 {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
+
---
|
|
429
240
|
|
|
430
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
-
|
|
252
|
+
// 2. mobile signs prepared.userOpHash via Privy personal_sign
|
|
451
253
|
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
+
## Status polling
|
|
470
266
|
|
|
471
267
|
```ts
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
278
|
+
---
|
|
491
279
|
|
|
492
|
-
|
|
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
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
509
|
-
- `PTRedeemHandler` enforces on-chain balance check before signing `BurnRequest`
|
|
295
|
+
---
|
|
510
296
|
|
|
511
|
-
|
|
512
|
-
- `BalanceAggregator`, `BurnIndexer`, `PTRedeemHandler`, `TopUpRedemptionHandler`
|
|
297
|
+
## License
|
|
513
298
|
|
|
514
|
-
|
|
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.
|
|
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.
|
|
26
|
+
"@pafi-dev/core": "0.6.2"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"viem": "^2.0.0"
|