@pafi-dev/app-ui
v0.4.0
Published
Mobile SDK for PAFI issuer app integration (React Native + Privy wallet)
Readme
@pafi-dev/app-ui
Mobile SDK for PAFI-integrated issuer apps. Provides a thin HTTP client, embedded Privy wallet management, and React hooks for React Native / Expo apps to integrate claim (mint), redeem (burn), and PAFI Web handoff flows.
Issuer app developers do not need to understand blockchain, wallets, or cryptographic signing — the SDK handles everything.
Platform support
| Platform | Supported | Notes | | --- | --- | --- | | iOS | ✅ | Requires Xcode 16+ | | Android | ✅ | Requires API 26+ | | Web | ❌ | Not supported — SDK depends on native Privy embedded wallet |
Requirements
- Node.js ≥ 18
- TypeScript ≥ 5.0
- Expo SDK 52+ (SDK 54 recommended)
- React Native ≥ 0.73.0
Install
pnpm add @pafi-dev/app-uiPeer dependencies
Issuer apps must install the following as peer dependencies:
| Package | Version | Note |
| --- | --- | --- |
| react | ^18.0.0 \|\| ^19.0.0 | React 19 recommended for Expo 54+ |
| react-native | >=0.73.0 | |
| expo | >=52.0.0 | Expo SDK 54 recommended |
| @privy-io/expo | ^0.64.0 \|\| ^0.65.0 | Wallet management + signing |
| react-native-get-random-values | >=1.0.0 | Required polyfill for crypto.getRandomValues |
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 viaeth_signTypedData_v4.
At a glance
import { PafiProvider, usePafiAuth, usePafiUser, usePafiClaim } from "@pafi-dev/app-ui";
// ── App root ──────────────────────────────────────────────────────
export default function App() {
return (
<PafiProvider
baseUrl="https://api.example.com"
getIssuerAccessToken={getIssuerAccessToken}
>
<Main />
</PafiProvider>
);
}
// ── Main screen ───────────────────────────────────────────────────
function Main() {
const { login, isAuthenticated, walletAddress } = usePafiAuth();
const { totalBalance, offChainBalance, onChainBalance, gasFeeUsdt } = usePafiUser();
const { claim, isSubmitting } = usePafiClaim();
if (!isAuthenticated) {
return <Button onPress={login} title="Login" />;
}
return (
<>
<Text>Wallet: {walletAddress}</Text>
<Text>Total Balance: {totalBalance} PT</Text>
<Text>Claimable: {offChainBalance} PT</Text>
<Text>On-chain: {onChainBalance} PT</Text>
<Button
onPress={() => claim({ amount: offChainBalance! })}
title={isSubmitting ? "Claiming..." : "Claim"}
disabled={isSubmitting}
/>
</>
);
}That's the entire integration. No wallets, no UserOps, no signing.
What you get
| Export | Type | Description |
| --- | --- | --- |
| PafiProvider | Component | Root provider — wraps PrivyProvider + PafiClient context |
| usePafiAuth | Hook | Login / logout with embedded Privy wallet + SIWE |
| usePafiUser | Hook | Balances (off-chain, on-chain, total), pools, gas fee |
| usePafiClaim | Hook | 2-step claim (mint): prepare → sign → submit |
| usePafiRedeem | Hook | 2-step redeem (burn): prepare → sign → submit |
| usePafiRedemptionPreview | Hook | v1.6 redemption policy preview + client-side validation |
| usePafiDelegation | Hook | EIP-7702 delegation (handled automatically during claim/redeem) |
| usePafiClaimStatus | Hook | Poll for claim (mint) transaction status |
| usePafiRedeemStatus | Hook | Poll for redeem (burn) transaction status |
| usePafiTransactions | Hook | Paginated transaction history |
| usePafiEmailLink | Hook | Link email to Privy wallet (for PAFI Web handoff) |
| usePafiWebHandoff | Hook | Open PAFI Web for swap / perp deposit |
| useTos | Hook | Observe issuer-owned TOS acceptance state |
| TosGate | Component | Conditionally renders children based on TOS acceptance |
| PafiClient | Class | Thin HTTP client (use directly if not using React) |
| PafiApiError | Class | Typed error for all backend responses |
| PafiTosDeclinedError | Class | Thrown when user declines the TOS modal |
| PafiTosRequiredError | Class | Thrown when claim/redeem is called before TOS acceptance |
The login flow
The issuer app calls login() — one function, no arguments. Internally:
login()
├── 1. Privy custom auth → silent, returns privyUserId (no modal)
├── 2. TOS check / accept → uses privyUserId, built-in modal if not yet accepted
├── 3. Wait for wallet ready → reactive, max 30s timeout (creates embedded wallet)
├── 4. GET /auth/nonce → backend returns random nonce
├── 5. buildSiweMessage(nonce) → SDK builds EIP-4361 message
├── 6. wallet.signMessage(siwe) → Privy signs (personal_sign)
├── 7. POST /auth/login → backend verifies → JWT
└── 8. client.setJwt(token) → authenticatedTOS is checked before wallet creation because privyUserId is available
immediately after Privy custom auth, while the embedded wallet is created
asynchronously. This avoids blocking the user behind both flows simultaneously.
const { login, logout, isAuthenticated, isReady, walletAddress, error } = usePafiAuth();
// Block UI until Privy SDK is initialized
if (!isReady) return <ActivityIndicator />;
// One call — SDK handles everything
<Button onPress={login} title="Login" />logout() calls POST /auth/logout to revoke the session server-side before
clearing the local JWT and Privy session.
Terms of Service (TOS)
TOS is an issuer-owned legal state. The SDK ships a built-in modal and orchestrates
the flow; the issuer backend is the source of truth for whether a user has accepted
a given TOS version. Claim, redeem, and delegation actions throw
PafiTosRequiredError until TOS is accepted.
The issuer backend must host two endpoints:
GET /tos/status?userId={privyUserId} → { accepted: boolean, version: string }
POST /tos/accept { privyUserId, version } → { success: boolean }<PafiProvider
baseUrl="https://api.example.com"
getIssuerAccessToken={getIssuerAccessToken}
tos={{
tosBaseUrl: "https://api.example.com",
version: "v1.0",
contentUrl: "https://api.example.com/legal/tos",
title: "Terms of Service",
branding: { accentColor: "#FF6B35" },
onDeclined: async () => {
// Optional issuer policy, e.g. navigate away.
},
}}
>
<TosGate fallback={<ActivityIndicator />} blocked={<TosRequired />}>
<PafiPoweredScreen />
</TosGate>
</PafiProvider>The TOS check runs automatically inside login() (after Privy custom auth,
before wallet creation). If tos is omitted, the gate is disabled and all
operations proceed without a TOS check.
const { isAccepted, isChecking, error, retry } = useTos();The SDK does not hard-code any TOS copy or URL — contentUrl is owned by the
issuer and rendered as a tappable link inside the built-in modal.
The claim flow
One-shot (no confirmation screen)
const { claim, isSubmitting, error } = usePafiClaim();
// One call — prepare + sign + submit
const { userOpHash } = await claim({ amount: "1000000000000000000" });With confirmation screen
const { prepare, confirm, preparedData, isPreparing, isSubmitting } = usePafiClaim();
// Step 1 — get a summary for the UI
await prepare({ amount: "1000000000000000000" });
// Step 2 — show the user what they'll receive
// preparedData.summary: { amount, gasFee, netPtMinted, expiresAt }
<Text>Minting: {preparedData.summary.amount} PT</Text>
<Text>Gas fee: {preparedData.summary.gasFee} PT</Text>
<Text>Net: {preparedData.summary.netPtMinted} PT</Text>
// Step 3 — user confirms → SDK signs + submits
<Button onPress={() => confirm(preparedData.lockId)} title="Confirm" />amount field
amount is a base-unit string (18 decimals). Pass offChainBalance from
usePafiUser() to claim the full claimable balance:
const { offChainBalance } = usePafiUser();
claim({ amount: offChainBalance! });The redeem flow
Redeem mirrors the claim flow — it burns on-chain PT and credits off-chain points.
One-shot
const { redeem, isSubmitting, error } = usePafiRedeem();
const { userOpHash } = await redeem({ amount: "1000000000000000000" });With confirmation screen
const { prepare, confirm, preparedData, isPreparing, isSubmitting } = usePafiRedeem();
// Step 1 — prepare
await prepare({ amount: "1000000000000000000" });
// Step 2 — show summary
// preparedData.summary: { amount, gasFee, netPtBurned, expiresAt }
<Text>Burning: {preparedData.summary.amount} PT</Text>
// Step 3 — user confirms
<Button onPress={() => confirm(preparedData.lockId)} title="Confirm" />Shortfall helper
For voucher redemption where the user may need to burn on-chain PT to cover a shortfall:
const { redeem, calculateShortfall } = usePafiRedeem();
const { offChainBalance } = usePafiUser();
const shortfall = calculateShortfall(voucherCostWei, offChainBalance!);
if (shortfall !== "0") {
await redeem({ amount: shortfall });
}Email linking
Email linking is deferred — users can view balances and claim immediately after login. Email is only linked when the user first needs PAFI Web access (e.g., Swap / Invest), and only once.
const { sendCode, confirmCode, status, isEmailLinked } = usePafiEmailLink();
// Gate swap behind email linking
const handleSwap = async () => {
if (!isEmailLinked) {
await sendCode(userEmail); // Privy sends OTP
showOtpModal(); // Show your OTP input UI
return;
}
// Proceed to swap
};
// After user enters OTP
const handleOtpSubmit = async (code: string) => {
await confirmCode({ code, email: userEmail });
// Email linked — proceed to swap
};| status | Meaning |
| --- | --- |
| idle | Not started |
| sending | Sending OTP to the user's email |
| awaiting_code | OTP sent, waiting for user input |
| confirming | Verifying OTP code with Privy |
| linked | Email successfully linked |
| error | Something went wrong — check error |
Transaction history
Paginated transaction history with infinite scroll support:
const { transactions, isLoading, hasMore, loadMore, refresh } = usePafiTransactions();
// Render list
<FlatList
data={transactions}
renderItem={({ item }) => (
<Text>{item.type}: {item.amount} PT — {item.status}</Text>
)}
onEndReached={() => hasMore && loadMore()}
refreshing={isLoading}
onRefresh={refresh}
/>PAFI Web handoff
Open PAFI Web so the user can swap, deposit, or manage their assets. The SDK provides the URL — the issuer app decides when and how to open it.
const { openPafiWeb, webUrl } = usePafiWebHandoff();
// Open PAFI Web homepage
const url = await openPafiWeb();
await Linking.openURL(url);Transaction polling
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:
import { usePafiClaimStatus } from "@pafi-dev/app-ui";
// lockId comes from the prepare() response, NOT userOpHash
const { status, txHash, isLoading, error } = usePafiClaimStatus(lockId);
if (status === "MINTED") {
console.log("Claim successful! txHash:", txHash);
}| Status | Meaning |
| --- | --- |
| PENDING | Tx submitted, awaiting on-chain confirmation |
| MINTED | Mint event indexed — balance deducted from ledger |
| EXPIRED | Prepare TTL expired before submit, or consent expired |
| FAILED | On-chain tx reverted |
Error handling
All backend errors are wrapped in PafiApiError:
import { PafiApiError } from "@pafi-dev/app-ui";
try {
await claim({ amount });
} catch (err) {
if (err instanceof PafiApiError) {
console.log(err.code); // "INSUFFICIENT_BALANCE", "POLICY_CAP_EXCEEDED", …
console.log(err.httpStatus); // 400, 401, 422, 500, …
console.log(err.safeToRetry); // false on claim errors — do NOT retry automatically
}
}Retry strategy (built into PafiClient)
| Condition | Action |
| --- | --- |
| 5xx + safeToRetry=true | Retry up to 3× with exponential backoff (500ms base, 5s cap, ±25% jitter) |
| 5xx + safeToRetry=false | Never retry — tx may be in mempool |
| 4xx | Never retry — client error |
| 401 | Clear JWT + throw — app must call login() again |
| Network error | Retry with same backoff (transient) — except /claim/submit, /redeem/submit, /delegate/submit: throw immediately (safeToRetry=false) |
PafiConfig reference
interface PafiConfig {
/** Issuer backend URL. Captured at mount — see "Config immutability" below. */
baseUrl: string;
/** Issuer JWT callback for Privy custom auth */
getIssuerAccessToken: () => Promise<string>;
/** Chain ID (default: 8453 — Base mainnet) */
chainId?: number;
/** Point token contract address */
pointTokenAddress?: string;
/** PAFI Web URL for swap/invest handoff */
pafiWebUrl?: string;
/** Custom fetch for testing / RN polyfills. Captured at mount. */
fetchFn?: typeof fetch;
/** Wallet mode. Default is "embedded". Note: only "embedded" is supported in production at this time. */
walletMode?: "embedded" | "external" | "both";
/** Additional Privy config overrides */
privyConfig?: Partial<PrivyClientConfig>;
/** Optional issuer-owned TOS gate (built-in modal + Issuer BE source of truth) */
tos?: TosConfig;
}Config immutability
baseUrl and fetchFn are captured once at mount when PafiClient is
constructed. Mutating them on the PafiProvider after mount has no effect —
the underlying client keeps using the original values. Re-mount the provider
(e.g., via key={baseUrl}) if you need to switch backends at runtime.
This is intentional: the JWT lifecycle is tied to a specific backend, and
swapping baseUrl mid-session would silently leak the JWT to a different host.
Architecture constraints
Mobile never builds UserOps or batch calls. The backend is the single
source of truth for UserOp construction. Mobile sends { amount }, receives
EIP-712 typed data, the SDK signs it via eth_signTypedData_v4 internally,
and the backend handles all blockchain complexity (UserOp assembly, paymaster,
bundler submission).
No runtime dependency on blockchain packages. @pafi-dev/core,
@pafi-dev/issuer, viem, and permissionless are NOT in dependencies.
No ABI encoding, no RPC calls, no contract reads happen in the mobile bundle.
JWT is stored in memory only. The SDK does not persist the JWT to disk.
If your app needs to survive process restarts without re-login, persist
token from LoginResponse yourself (e.g. expo-secure-store) and call
client.setJwt() on startup.
Exports
import {
// Provider + context
PafiProvider,
type PafiProviderProps,
usePafiClient,
// Hooks
usePafiAuth,
type UsePafiAuthReturn,
usePafiUser,
type UsePafiUserReturn,
usePafiClaim,
type UsePafiClaimReturn,
usePafiRedeem,
type UsePafiRedeemReturn,
usePafiRedemptionPreview,
type UsePafiRedemptionPreviewReturn,
usePafiDelegation,
type UsePafiDelegationReturn,
usePafiClaimStatus,
type UsePafiClaimStatusReturn,
usePafiRedeemStatus,
type UsePafiRedeemStatusReturn,
usePafiTransactions,
type UsePafiTransactionsReturn,
usePafiEmailLink,
type UsePafiEmailLinkReturn,
type EmailLinkStatus,
usePafiWebHandoff,
type UsePafiWebHandoffReturn,
// TOS
TosGate,
useTos,
PafiTosDeclinedError,
PafiTosRequiredError,
type TosConfig,
type TosBrandingConfig,
type TosStatus,
type TosGateProps,
type UseTosReturn,
type TosStatusResponse,
type TosAcceptResponse,
// HTTP client
PafiClient,
PafiApiError,
type PafiErrorType,
type PafiErrorPayload,
type PafiErrorMeta,
type PafiErrorResponse,
// Config types
type PafiConfig,
type PafiClientConfig,
// Wire types — auth
type NonceResponse,
type LoginRequest,
type LoginResponse,
// Wire types — user
type UserResponse,
type PoolKey,
type PoolsResponse,
// Wire types — claim (mint)
type PrepareClaimRequest,
type PrepareClaimResponse,
type ClaimSummary,
type SubmitClaimRequest,
type SubmitClaimResponse,
type ClaimStatusResponse,
// Wire types — redeem (burn)
type PrepareRedeemRequest,
type PrepareRedeemResponse,
type RedeemSummary,
type SubmitRedeemRequest,
type SubmitRedeemResponse,
type RedeemStatusResponse,
// Wire types — redemption policy (v1.6)
type RedemptionDenialCode,
type RedemptionClientValidationCode,
type RedemptionPreviewResponse,
type RedemptionEvaluateRequest,
type RedemptionEvaluateResponse,
type RedemptionDenial,
// Wire types — delegation
type DelegationStatusResponse,
type PrepareDelegationRequest,
type PrepareDelegationResponse,
type SubmitDelegationRequest,
type SubmitDelegationResponse,
// Wire types — web handoff
type WebHandoffResponse,
// Shared types
type EIP712TypedData,
type TransactionRecord,
type TransactionsResponse,
} from "@pafi-dev/app-ui";Tests
pnpm --filter @pafi-dev/app-ui testAll tests are hermetic — no network, no on-chain state required.
Changelog
See CHANGELOG.md for the authoritative changelog (current: 0.3.0 — Uniswap V3 migration + accumulated unreleased changes).
License
Apache-2.0
