@pafi-dev/app-ui 0.3.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 ADDED
@@ -0,0 +1,584 @@
1
+ # @pafi-dev/app-ui
2
+
3
+ [![npm](https://img.shields.io/npm/v/@pafi-dev/app-ui)](https://www.npmjs.com/package/@pafi-dev/app-ui)
4
+ [![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
5
+
6
+ Mobile SDK for PAFI-integrated issuer apps. Provides a thin HTTP client,
7
+ embedded Privy wallet management, and React hooks for React Native / Expo
8
+ apps to integrate claim (mint), redeem (burn), and PAFI Web handoff flows.
9
+
10
+ Issuer app developers do not need to understand blockchain, wallets, or
11
+ cryptographic signing — the SDK handles everything.
12
+
13
+ ### Platform support
14
+
15
+ | Platform | Supported | Notes |
16
+ | --- | --- | --- |
17
+ | iOS | ✅ | Requires Xcode 16+ |
18
+ | Android | ✅ | Requires API 26+ |
19
+ | Web | ❌ | Not supported — SDK depends on native Privy embedded wallet |
20
+
21
+ ## Requirements
22
+
23
+ - Node.js ≥ 18
24
+ - TypeScript ≥ 5.0
25
+ - Expo SDK 52+ (SDK 54 recommended)
26
+ - React Native ≥ 0.73.0
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pnpm add @pafi-dev/app-ui
32
+ ```
33
+
34
+ ### Peer dependencies
35
+
36
+ Issuer apps must install the following as peer dependencies:
37
+
38
+ | Package | Version | Note |
39
+ | --- | --- | --- |
40
+ | `react` | `^18.0.0 \|\| ^19.0.0` | React 19 recommended for Expo 54+ |
41
+ | `react-native` | `>=0.73.0` | |
42
+ | `expo` | `>=52.0.0` | Expo SDK 54 recommended |
43
+ | `@privy-io/expo` | `^0.64.0 \|\| ^0.65.0` | Wallet management + signing |
44
+ | `react-native-get-random-values` | `>=1.0.0` | Required polyfill for `crypto.getRandomValues` |
45
+
46
+ > **No web3 libraries required.** The SDK does not depend on `viem`, `ethers`, `permissionless`, or any other blockchain library. The backend builds all UserOps and returns EIP-712 typed data; the mobile SDK signs it via `eth_signTypedData_v4`.
47
+
48
+ ## At a glance
49
+
50
+ ```tsx
51
+ import { PafiProvider, usePafiAuth, usePafiUser, usePafiClaim } from "@pafi-dev/app-ui";
52
+
53
+ // ── App root ──────────────────────────────────────────────────────
54
+ export default function App() {
55
+ return (
56
+ <PafiProvider
57
+ baseUrl="https://api.example.com"
58
+ getIssuerAccessToken={getIssuerAccessToken}
59
+ >
60
+ <Main />
61
+ </PafiProvider>
62
+ );
63
+ }
64
+
65
+ // ── Main screen ───────────────────────────────────────────────────
66
+ function Main() {
67
+ const { login, isAuthenticated, walletAddress } = usePafiAuth();
68
+ const { totalBalance, offChainBalance, onChainBalance, gasFeeUsdt } = usePafiUser();
69
+ const { claim, isSubmitting } = usePafiClaim();
70
+
71
+ if (!isAuthenticated) {
72
+ return <Button onPress={login} title="Login" />;
73
+ }
74
+
75
+ return (
76
+ <>
77
+ <Text>Wallet: {walletAddress}</Text>
78
+ <Text>Total Balance: {totalBalance} PT</Text>
79
+ <Text>Claimable: {offChainBalance} PT</Text>
80
+ <Text>On-chain: {onChainBalance} PT</Text>
81
+ <Button
82
+ onPress={() => claim({ amount: offChainBalance! })}
83
+ title={isSubmitting ? "Claiming..." : "Claim"}
84
+ disabled={isSubmitting}
85
+ />
86
+ </>
87
+ );
88
+ }
89
+ ```
90
+
91
+ That's the entire integration. No wallets, no UserOps, no signing.
92
+
93
+ ## What you get
94
+
95
+ | Export | Type | Description |
96
+ | --- | --- | --- |
97
+ | `PafiProvider` | Component | Root provider — wraps PrivyProvider + PafiClient context |
98
+ | `usePafiAuth` | Hook | Login / logout with embedded Privy wallet + SIWE |
99
+ | `usePafiUser` | Hook | Balances (off-chain, on-chain, total), pools, gas fee |
100
+ | `usePafiClaim` | Hook | 2-step claim (mint): prepare → sign → submit |
101
+ | `usePafiRedeem` | Hook | 2-step redeem (burn): prepare → sign → submit |
102
+ | `usePafiRedemptionPreview` | Hook | v1.6 redemption policy preview + client-side validation |
103
+ | `usePafiDelegation` | Hook | EIP-7702 delegation (handled automatically during claim/redeem) |
104
+ | `usePafiClaimStatus` | Hook | Poll for claim (mint) transaction status |
105
+ | `usePafiRedeemStatus` | Hook | Poll for redeem (burn) transaction status |
106
+ | `usePafiTransactions` | Hook | Paginated transaction history |
107
+ | `usePafiEmailLink` | Hook | Link email to Privy wallet (for PAFI Web handoff) |
108
+ | `usePafiWebHandoff` | Hook | Open PAFI Web for swap / perp deposit |
109
+ | `useTos` | Hook | Observe issuer-owned TOS acceptance state |
110
+ | `TosGate` | Component | Conditionally renders children based on TOS acceptance |
111
+ | `PafiClient` | Class | Thin HTTP client (use directly if not using React) |
112
+ | `PafiApiError` | Class | Typed error for all backend responses |
113
+ | `PafiTosDeclinedError` | Class | Thrown when user declines the TOS modal |
114
+ | `PafiTosRequiredError` | Class | Thrown when claim/redeem is called before TOS acceptance |
115
+
116
+ ## The login flow
117
+
118
+ The issuer app calls `login()` — one function, no arguments. Internally:
119
+
120
+ ```
121
+ login()
122
+ ├── 1. Privy custom auth → silent, returns privyUserId (no modal)
123
+ ├── 2. TOS check / accept → uses privyUserId, built-in modal if not yet accepted
124
+ ├── 3. Wait for wallet ready → reactive, max 30s timeout (creates embedded wallet)
125
+ ├── 4. GET /auth/nonce → backend returns random nonce
126
+ ├── 5. buildSiweMessage(nonce) → SDK builds EIP-4361 message
127
+ ├── 6. wallet.signMessage(siwe) → Privy signs (personal_sign)
128
+ ├── 7. POST /auth/login → backend verifies → JWT
129
+ └── 8. client.setJwt(token) → authenticated
130
+ ```
131
+
132
+ TOS is checked **before** wallet creation because `privyUserId` is available
133
+ immediately after Privy custom auth, while the embedded wallet is created
134
+ asynchronously. This avoids blocking the user behind both flows simultaneously.
135
+
136
+ ```tsx
137
+ const { login, logout, isAuthenticated, isReady, walletAddress, error } = usePafiAuth();
138
+
139
+ // Block UI until Privy SDK is initialized
140
+ if (!isReady) return <ActivityIndicator />;
141
+
142
+ // One call — SDK handles everything
143
+ <Button onPress={login} title="Login" />
144
+ ```
145
+
146
+ `logout()` calls `POST /auth/logout` to revoke the session server-side before
147
+ clearing the local JWT and Privy session.
148
+
149
+ ## Terms of Service (TOS)
150
+
151
+ TOS is an issuer-owned legal state. The SDK ships a built-in modal and orchestrates
152
+ the flow; the issuer backend is the source of truth for whether a user has accepted
153
+ a given TOS version. Claim, redeem, and delegation actions throw
154
+ `PafiTosRequiredError` until TOS is accepted.
155
+
156
+ The issuer backend must host two endpoints:
157
+
158
+ ```
159
+ GET /tos/status?userId={privyUserId} → { accepted: boolean, version: string }
160
+ POST /tos/accept { privyUserId, version } → { success: boolean }
161
+ ```
162
+
163
+ ```tsx
164
+ <PafiProvider
165
+ baseUrl="https://api.example.com"
166
+ getIssuerAccessToken={getIssuerAccessToken}
167
+ tos={{
168
+ tosBaseUrl: "https://api.example.com",
169
+ version: "v1.0",
170
+ contentUrl: "https://api.example.com/legal/tos",
171
+ title: "Terms of Service",
172
+ branding: { accentColor: "#FF6B35" },
173
+ onDeclined: async () => {
174
+ // Optional issuer policy, e.g. navigate away.
175
+ },
176
+ }}
177
+ >
178
+ <TosGate fallback={<ActivityIndicator />} blocked={<TosRequired />}>
179
+ <PafiPoweredScreen />
180
+ </TosGate>
181
+ </PafiProvider>
182
+ ```
183
+
184
+ The TOS check runs automatically inside `login()` (after Privy custom auth,
185
+ before wallet creation). If `tos` is omitted, the gate is disabled and all
186
+ operations proceed without a TOS check.
187
+
188
+ ```tsx
189
+ const { isAccepted, isChecking, error, retry } = useTos();
190
+ ```
191
+
192
+ The SDK does not hard-code any TOS copy or URL — `contentUrl` is owned by the
193
+ issuer and rendered as a tappable link inside the built-in modal.
194
+
195
+ ## The claim flow
196
+
197
+ ### One-shot (no confirmation screen)
198
+
199
+ ```tsx
200
+ const { claim, isSubmitting, error } = usePafiClaim();
201
+
202
+ // One call — prepare + sign + submit
203
+ const { userOpHash } = await claim({ amount: "1000000000000000000" });
204
+ ```
205
+
206
+ ### With confirmation screen
207
+
208
+ ```tsx
209
+ const { prepare, confirm, preparedData, isPreparing, isSubmitting } = usePafiClaim();
210
+
211
+ // Step 1 — get a summary for the UI
212
+ await prepare({ amount: "1000000000000000000" });
213
+
214
+ // Step 2 — show the user what they'll receive
215
+ // preparedData.summary: { amount, gasFee, netPtMinted, expiresAt }
216
+ <Text>Minting: {preparedData.summary.amount} PT</Text>
217
+ <Text>Gas fee: {preparedData.summary.gasFee} PT</Text>
218
+ <Text>Net: {preparedData.summary.netPtMinted} PT</Text>
219
+
220
+ // Step 3 — user confirms → SDK signs + submits
221
+ <Button onPress={() => confirm(preparedData.lockId)} title="Confirm" />
222
+ ```
223
+
224
+ ### amount field
225
+
226
+ `amount` is a base-unit string (18 decimals). Pass `offChainBalance` from
227
+ `usePafiUser()` to claim the full claimable balance:
228
+
229
+ ```tsx
230
+ const { offChainBalance } = usePafiUser();
231
+ claim({ amount: offChainBalance! });
232
+ ```
233
+
234
+ ## The redeem flow
235
+
236
+ Redeem mirrors the claim flow — it burns on-chain PT and credits off-chain points.
237
+
238
+ ### One-shot
239
+
240
+ ```tsx
241
+ const { redeem, isSubmitting, error } = usePafiRedeem();
242
+
243
+ const { userOpHash } = await redeem({ amount: "1000000000000000000" });
244
+ ```
245
+
246
+ ### With confirmation screen
247
+
248
+ ```tsx
249
+ const { prepare, confirm, preparedData, isPreparing, isSubmitting } = usePafiRedeem();
250
+
251
+ // Step 1 — prepare
252
+ await prepare({ amount: "1000000000000000000" });
253
+
254
+ // Step 2 — show summary
255
+ // preparedData.summary: { amount, gasFee, netPtBurned, expiresAt }
256
+ <Text>Burning: {preparedData.summary.amount} PT</Text>
257
+
258
+ // Step 3 — user confirms
259
+ <Button onPress={() => confirm(preparedData.lockId)} title="Confirm" />
260
+ ```
261
+
262
+ ### Shortfall helper
263
+
264
+ For voucher redemption where the user may need to burn on-chain PT to cover a shortfall:
265
+
266
+ ```tsx
267
+ const { redeem, calculateShortfall } = usePafiRedeem();
268
+ const { offChainBalance } = usePafiUser();
269
+
270
+ const shortfall = calculateShortfall(voucherCostWei, offChainBalance!);
271
+ if (shortfall !== "0") {
272
+ await redeem({ amount: shortfall });
273
+ }
274
+ ```
275
+
276
+ ## Email linking
277
+
278
+ Email linking is deferred — users can view balances and claim immediately
279
+ after login. Email is only linked when the user first needs PAFI Web access
280
+ (e.g., Swap / Invest), and only once.
281
+
282
+ ```tsx
283
+ const { sendCode, confirmCode, status, isEmailLinked } = usePafiEmailLink();
284
+
285
+ // Gate swap behind email linking
286
+ const handleSwap = async () => {
287
+ if (!isEmailLinked) {
288
+ await sendCode(userEmail); // Privy sends OTP
289
+ showOtpModal(); // Show your OTP input UI
290
+ return;
291
+ }
292
+ // Proceed to swap
293
+ };
294
+
295
+ // After user enters OTP
296
+ const handleOtpSubmit = async (code: string) => {
297
+ await confirmCode({ code, email: userEmail });
298
+ // Email linked — proceed to swap
299
+ };
300
+ ```
301
+
302
+ | `status` | Meaning |
303
+ | --- | --- |
304
+ | `idle` | Not started |
305
+ | `sending` | Sending OTP to the user's email |
306
+ | `awaiting_code` | OTP sent, waiting for user input |
307
+ | `confirming` | Verifying OTP code with Privy |
308
+ | `linked` | Email successfully linked |
309
+ | `error` | Something went wrong — check `error` |
310
+
311
+ ## Transaction history
312
+
313
+ Paginated transaction history with infinite scroll support:
314
+
315
+ ```tsx
316
+ const { transactions, isLoading, hasMore, loadMore, refresh } = usePafiTransactions();
317
+
318
+ // Render list
319
+ <FlatList
320
+ data={transactions}
321
+ renderItem={({ item }) => (
322
+ <Text>{item.type}: {item.amount} PT — {item.status}</Text>
323
+ )}
324
+ onEndReached={() => hasMore && loadMore()}
325
+ refreshing={isLoading}
326
+ onRefresh={refresh}
327
+ />
328
+ ```
329
+
330
+ ## PAFI Web handoff
331
+
332
+ Open PAFI Web so the user can swap, deposit, or manage their assets.
333
+ The SDK provides the URL — the issuer app decides when and how to open it.
334
+
335
+ ```tsx
336
+ const { openPafiWeb, webUrl } = usePafiWebHandoff();
337
+
338
+ // Open PAFI Web homepage
339
+ const url = await openPafiWeb();
340
+ await Linking.openURL(url);
341
+ ```
342
+
343
+ ## Transaction polling
344
+
345
+ After a claim or redeem, the on-chain execution is asynchronous. You can use the dedicated status hooks to poll until the transaction reaches a terminal status:
346
+
347
+ ```tsx
348
+ import { usePafiClaimStatus } from "@pafi-dev/app-ui";
349
+
350
+ // lockId comes from the prepare() response, NOT userOpHash
351
+ const { status, txHash, isLoading, error } = usePafiClaimStatus(lockId);
352
+
353
+ if (status === "MINTED") {
354
+ console.log("Claim successful! txHash:", txHash);
355
+ }
356
+ ```
357
+
358
+ | Status | Meaning |
359
+ | --- | --- |
360
+ | `PENDING` | Tx submitted, awaiting on-chain confirmation |
361
+ | `MINTED` | Mint event indexed — balance deducted from ledger |
362
+ | `EXPIRED` | Prepare TTL expired before submit, or consent expired |
363
+ | `FAILED` | On-chain tx reverted |
364
+
365
+ ## Error handling
366
+
367
+ All backend errors are wrapped in `PafiApiError`:
368
+
369
+ ```ts
370
+ import { PafiApiError } from "@pafi-dev/app-ui";
371
+
372
+ try {
373
+ await claim({ amount });
374
+ } catch (err) {
375
+ if (err instanceof PafiApiError) {
376
+ console.log(err.code); // "INSUFFICIENT_BALANCE", "POLICY_CAP_EXCEEDED", …
377
+ console.log(err.httpStatus); // 400, 401, 422, 500, …
378
+ console.log(err.safeToRetry); // false on claim errors — do NOT retry automatically
379
+ }
380
+ }
381
+ ```
382
+
383
+ ### Retry strategy (built into `PafiClient`)
384
+
385
+ | Condition | Action |
386
+ | --- | --- |
387
+ | `5xx` + `safeToRetry=true` | Retry up to 3× with exponential backoff (500ms base, 5s cap, ±25% jitter) |
388
+ | `5xx` + `safeToRetry=false` | **Never** retry — tx may be in mempool |
389
+ | `4xx` | Never retry — client error |
390
+ | `401` | Clear JWT + throw — app must call `login()` again |
391
+ | Network error | Retry with same backoff (transient) — **except** `/claim/submit`, `/redeem/submit`, `/delegate/submit`: throw immediately (`safeToRetry=false`) |
392
+
393
+ ## `PafiConfig` reference
394
+
395
+ ```ts
396
+ interface PafiConfig {
397
+ /** Issuer backend URL. Captured at mount — see "Config immutability" below. */
398
+ baseUrl: string;
399
+ /** Issuer JWT callback for Privy custom auth */
400
+ getIssuerAccessToken: () => Promise<string>;
401
+ /** Chain ID (default: 8453 — Base mainnet) */
402
+ chainId?: number;
403
+ /** Point token contract address */
404
+ pointTokenAddress?: string;
405
+ /** PAFI Web URL for swap/invest handoff */
406
+ pafiWebUrl?: string;
407
+ /** Custom fetch for testing / RN polyfills. Captured at mount. */
408
+ fetchFn?: typeof fetch;
409
+ /** Wallet mode. Default is "embedded". Note: only "embedded" is supported in production at this time. */
410
+ walletMode?: "embedded" | "external" | "both";
411
+ /** Additional Privy config overrides */
412
+ privyConfig?: Partial<PrivyClientConfig>;
413
+ /** Optional issuer-owned TOS gate (built-in modal + Issuer BE source of truth) */
414
+ tos?: TosConfig;
415
+ }
416
+ ```
417
+
418
+ ### Config immutability
419
+
420
+ `baseUrl` and `fetchFn` are captured **once at mount** when `PafiClient` is
421
+ constructed. Mutating them on the `PafiProvider` after mount has no effect —
422
+ the underlying client keeps using the original values. Re-mount the provider
423
+ (e.g., via `key={baseUrl}`) if you need to switch backends at runtime.
424
+
425
+ This is intentional: the JWT lifecycle is tied to a specific backend, and
426
+ swapping `baseUrl` mid-session would silently leak the JWT to a different host.
427
+
428
+ ## Architecture constraints
429
+
430
+ **Mobile never builds UserOps or batch calls.** The backend is the single
431
+ source of truth for UserOp construction. Mobile sends `{ amount }`, receives
432
+ EIP-712 typed data, the SDK signs it via `eth_signTypedData_v4` internally,
433
+ and the backend handles all blockchain complexity (UserOp assembly, paymaster,
434
+ bundler submission).
435
+
436
+ **No runtime dependency on blockchain packages.** `@pafi-dev/core`,
437
+ `@pafi-dev/issuer`, `viem`, and `permissionless` are NOT in dependencies.
438
+ No ABI encoding, no RPC calls, no contract reads happen in the mobile bundle.
439
+
440
+ **JWT is stored in memory only.** The SDK does not persist the JWT to disk.
441
+ If your app needs to survive process restarts without re-login, persist
442
+ `token` from `LoginResponse` yourself (e.g. `expo-secure-store`) and call
443
+ `client.setJwt()` on startup.
444
+
445
+ ## Exports
446
+
447
+ ```ts
448
+ import {
449
+ // Provider + context
450
+ PafiProvider,
451
+ type PafiProviderProps,
452
+ usePafiClient,
453
+
454
+ // Hooks
455
+ usePafiAuth,
456
+ type UsePafiAuthReturn,
457
+ usePafiUser,
458
+ type UsePafiUserReturn,
459
+ usePafiClaim,
460
+ type UsePafiClaimReturn,
461
+ usePafiRedeem,
462
+ type UsePafiRedeemReturn,
463
+ usePafiRedemptionPreview,
464
+ type UsePafiRedemptionPreviewReturn,
465
+ usePafiDelegation,
466
+ type UsePafiDelegationReturn,
467
+ usePafiClaimStatus,
468
+ type UsePafiClaimStatusReturn,
469
+ usePafiRedeemStatus,
470
+ type UsePafiRedeemStatusReturn,
471
+ usePafiTransactions,
472
+ type UsePafiTransactionsReturn,
473
+ usePafiEmailLink,
474
+ type UsePafiEmailLinkReturn,
475
+ type EmailLinkStatus,
476
+ usePafiWebHandoff,
477
+ type UsePafiWebHandoffReturn,
478
+
479
+ // TOS
480
+ TosGate,
481
+ useTos,
482
+ PafiTosDeclinedError,
483
+ PafiTosRequiredError,
484
+ type TosConfig,
485
+ type TosBrandingConfig,
486
+ type TosStatus,
487
+ type TosGateProps,
488
+ type UseTosReturn,
489
+ type TosStatusResponse,
490
+ type TosAcceptResponse,
491
+
492
+ // HTTP client
493
+ PafiClient,
494
+ PafiApiError,
495
+ type PafiErrorType,
496
+ type PafiErrorPayload,
497
+ type PafiErrorMeta,
498
+ type PafiErrorResponse,
499
+
500
+ // Config types
501
+ type PafiConfig,
502
+ type PafiClientConfig,
503
+
504
+ // Wire types — auth
505
+ type NonceResponse,
506
+ type LoginRequest,
507
+ type LoginResponse,
508
+
509
+ // Wire types — user
510
+ type UserResponse,
511
+ type PoolKey,
512
+ type PoolsResponse,
513
+
514
+ // Wire types — claim (mint)
515
+ type PrepareClaimRequest,
516
+ type PrepareClaimResponse,
517
+ type ClaimSummary,
518
+ type SubmitClaimRequest,
519
+ type SubmitClaimResponse,
520
+ type ClaimStatusResponse,
521
+
522
+ // Wire types — redeem (burn)
523
+ type PrepareRedeemRequest,
524
+ type PrepareRedeemResponse,
525
+ type RedeemSummary,
526
+ type SubmitRedeemRequest,
527
+ type SubmitRedeemResponse,
528
+ type RedeemStatusResponse,
529
+
530
+ // Wire types — redemption policy (v1.6)
531
+ type RedemptionDenialCode,
532
+ type RedemptionClientValidationCode,
533
+ type RedemptionPreviewResponse,
534
+ type RedemptionEvaluateRequest,
535
+ type RedemptionEvaluateResponse,
536
+ type RedemptionDenial,
537
+
538
+ // Wire types — delegation
539
+ type DelegationStatusResponse,
540
+ type PrepareDelegationRequest,
541
+ type PrepareDelegationResponse,
542
+ type SubmitDelegationRequest,
543
+ type SubmitDelegationResponse,
544
+
545
+ // Wire types — web handoff
546
+ type WebHandoffResponse,
547
+
548
+ // Shared types
549
+ type EIP712TypedData,
550
+ type TransactionRecord,
551
+ type TransactionsResponse,
552
+ } from "@pafi-dev/app-ui";
553
+ ```
554
+
555
+ ## Tests
556
+
557
+ ```bash
558
+ pnpm --filter @pafi-dev/app-ui test
559
+ ```
560
+
561
+ All tests are hermetic — no network, no on-chain state required.
562
+
563
+ ## Changelog
564
+
565
+ ### 0.2.1
566
+
567
+ - Added `usePafiClaimStatus` and `usePafiRedeemStatus` for transaction polling
568
+ - Added `usePafiTransactions` for paginated transaction history
569
+ - Added `usePafiRedemptionPreview` for v1.6 redemption policy preview
570
+ - Added `TosGate` component and `useTos` hook (replaces the wallet-consent
571
+ prototype from earlier drafts — the consent API is no longer exported)
572
+ - EIP-712 `signTypedData` for all signing (replaced `personal_sign`)
573
+ - Added EIP-7702 v normalization (27/28 → 0/1 yParity)
574
+
575
+ ### 0.2.0
576
+
577
+ - Initial public release
578
+ - `PafiProvider`, `usePafiAuth`, `usePafiUser`, `usePafiClaim`, `usePafiRedeem`
579
+ - `usePafiDelegation`, `usePafiEmailLink`, `usePafiWebHandoff`
580
+ - `PafiClient` HTTP client with retry + exponential backoff
581
+
582
+ ## License
583
+
584
+ Apache-2.0