@pafi-dev/issuer-fe
React SDK for partner issuer web frontends integrating with the PAFI Wallet Auth Gateway + their own issuer backend.
Pairs with:
@pafi-dev/issuer/direct-authon the BE (session token minting)@pafi-dev/issuer/mint-burnon the BE (on-chain mint/burn signing)
What you get
| Hook / Class | Purpose |
|---|---|
usePafiAuth |
Email OTP / Google / Kakao login flows against pafi-auth |
PafiAuthClient |
Framework-agnostic direct-auth HTTP client (for Vue/Svelte/vanilla) |
usePafiIssuer |
/claim (mint PT) + /redeem (burn PT) against your issuer BE |
PafiIssuerClient |
Framework-agnostic issuer BE HTTP client |
usePafiUserProfile |
Poll GET /users/me — off-chain + on-chain + total balance from issuer BE |
usePafiUserHistory |
Paginated ledger transaction list from issuer BE |
usePafiBalance |
Poll ERC-20 + native balances direct from Arbitrum RPC via viem |
formatBalance / parseBalance / sumBalances |
bigint ⇄ human string helpers |
What you DO NOT get (and why)
- No Privy integration — issuer FE doesn't hold user tokens or sign
UserOps. That's the pafi.xyz path (
@pafi-dev/wallet-react). - No wallet signing — the issuer BE owns the minter/burner key and signs mint/burn transactions server-side. FE just triggers.
- No transfer/swap — issuer FEs are point-redemption surfaces, not wallets. Users trade PT USDT on pafi.xyz.
This is Trust Boundary Fix §5 in code: issuer FEs are display + trigger, never signers.
Install
pnpm add @pafi-dev/issuer-fe viem
Peer deps: react (18 or 19), viem (2.x).
Quickstart — full login → claim → balance flow
import { useMemo } from 'react';
import { createPublicClient, http } from 'viem';
import { arbitrum } from 'viem/chains';
import {
usePafiAuth,
usePafiIssuer,
usePafiBalance,
formatBalance,
} from '@pafi-dev/issuer-fe';
const PAFI_AUTH_BASE = 'https://api-stg.pacificfinance.org/api/pafi-auth';
const ISSUER_API = 'https://gg56-api-stg.pacificfinance.org';
const PT = '0x26Ca5e61458dF9F8C89c117F92192C31C63b042A';
export function App() {
const auth = usePafiAuth({
baseUrl: PAFI_AUTH_BASE,
onLogin: (user) => {
// Persist your issuer BE session using user.canonical_id /
// user.wallet_address / user.pafi_session_token — however your
// BE mints its own session token.
localStorage.setItem('canonical_id', user.canonical_id);
},
});
const issuer = usePafiIssuer({
baseUrl: ISSUER_API,
// Return your issuer BE session token — SDK sends it as Bearer.
getAccessToken: async () => localStorage.getItem('issuer_session_token'),
onClaimed: (r) => console.log('minted', r.txHash),
});
const publicClient = useMemo(
() => createPublicClient({ chain: arbitrum, transport: http() }),
[],
);
const balance = usePafiBalance({
publicClient,
wallet: auth.user?.wallet_address,
tokens: [
{ key: 'PT', address: PT, decimals: 18 },
{ key: 'ETH', address: null, decimals: 18 },
],
});
if (!auth.user) return <LoginForm auth={auth} />;
return (
<div>
<p>Signed in as {auth.user.verified_email}</p>
<p>Wallet: <code>{auth.user.wallet_address}</code></p>
<p>PT balance: {formatBalance(balance.balances.PT ?? 0n, 18)}</p>
<button
disabled={issuer.loading.claim}
onClick={() => issuer.claim({ amount: (10n ** 20n).toString() /* 100 PT */ })}
>
{issuer.loading.claim ? 'Minting…' : 'Claim 100 PT'}
</button>
{issuer.lastClaim && (
<p>Last tx: <code>{issuer.lastClaim.txHash}</code></p>
)}
<button onClick={auth.signOut}>Sign out</button>
</div>
);
}
Balance sources — which hook to use?
Two orthogonal sources of "how many points does this user have?":
| Hook | Source | When to use |
|---|---|---|
usePafiUserProfile |
Issuer BE GET /users/me — {offChainBalance, onChainBalance, totalBalance} |
The primary number your UI shows. Issuer BE joins off-chain ledger + on-chain balance in one round-trip. |
usePafiBalance |
Arbitrum RPC direct via viem — pure on-chain | Debugging, showing PT + USDT + ETH in one panel, or when issuer BE is down but you still want to render on-chain state. |
For most issuers: use usePafiUserProfile for the headline balance, usePafiBalance only if you need multi-token breakdown.
User profile example — merged off-chain + on-chain
import { usePafiUserProfile, formatBalance } from '@pafi-dev/issuer-fe';
function BalanceCard({ auth, issuer }) {
const profile = usePafiUserProfile({
baseUrl: ISSUER_API,
getAccessToken: async () => localStorage.getItem('issuer_session'),
enabled: !!auth.user,
onData: (p) => console.log('total points:', p.totalBalance),
});
// Wire refresh to fire the instant a claim/redeem lands
useEffect(() => {
if (issuer.lastClaim || issuer.lastRedeem) profile.refresh();
}, [issuer.lastClaim, issuer.lastRedeem, profile]);
if (profile.loading && !profile.profile) return <div>Loading…</div>;
if (profile.error) return <div>Error: {profile.error.message}</div>;
if (!profile.profile) return null;
return (
<div>
<div>Points: {formatBalance(profile.profile.totalBalance, 18)}</div>
<div style={{fontSize: 12, color: '#888'}}>
Off-chain: {formatBalance(profile.profile.offChainBalance, 18)} ·
On-chain: {formatBalance(profile.profile.onChainBalance, 18)}
</div>
</div>
);
}
Transaction history example
import { usePafiUserHistory, formatBalance } from '@pafi-dev/issuer-fe';
function HistoryList({ auth }) {
const history = usePafiUserHistory({
baseUrl: ISSUER_API,
getAccessToken: async () => localStorage.getItem('issuer_session'),
enabled: !!auth.user,
pageSize: 20,
});
return (
<ul>
{history.entries.map((e) => (
<li key={e.id}>
<span>{new Date(e.createdAt).toLocaleString()}</span>
<span style={{color: BigInt(e.delta) > 0n ? 'green' : 'red'}}>
{BigInt(e.delta) > 0n ? '+' : ''}{formatBalance(e.delta, 18)}
</span>
<span>{e.reason}</span>
{e.txHash && <a href={`https://arbiscan.io/tx/${e.txHash}`}>tx</a>}
</li>
))}
{history.hasMore && (
<button disabled={history.loading} onClick={history.loadMore}>
Load more
</button>
)}
</ul>
);
}
Issuer BE contract — endpoints this SDK expects
If your BE is the gg56 / lotteria boilerplate, /claim + /redeem + /redeem/status/:lockId are already there. For the new profile + history hooks, add:
// Nest example — server-side
@Controller('users')
export class UsersController {
@Get('me')
@UseGuards(JwtGuard)
async me(@AuthUser() user): Promise<UserProfileResponse> {
const offChain = await this.ledger.getBalance(user.walletAddress);
const onChain = await this.publicClient.readContract({
address: PT_ADDRESS,
abi: ERC20_ABI, functionName: 'balanceOf', args: [user.walletAddress],
});
return {
offChainBalance: offChain.toString(),
onChainBalance: onChain.toString(),
totalBalance: (offChain + onChain).toString(),
isMinter: false,
};
}
@Get('me/history')
@UseGuards(JwtGuard)
async history(
@AuthUser() user,
@Query('limit') limit = '50',
@Query('offset') offset = '0',
): Promise<HistoryResponse> {
const { rows, hasMore } = await this.ledger.getJournal({
userAddress: user.walletAddress,
limit: +limit,
offset: +offset,
});
return { entries: rows, hasMore };
}
}
Login form example (email OTP)
function LoginForm({ auth }: { auth: ReturnType<typeof usePafiAuth> }) {
const [email, setEmail] = useState('');
const [otp, setOtp] = useState('');
const [challengeId, setChallengeId] = useState<string | null>(null);
async function sendCode() {
const { challenge_id } = await auth.startEmailOtp(email);
setChallengeId(challenge_id);
}
async function verify() {
await auth.verifyEmailOtp({ email, otp, challenge_id: challengeId! });
// usePafiAuth stores user in state → onLogin callback fires
}
return (
<div>
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@email.com" />
<button onClick={sendCode} disabled={auth.loading}>Send code</button>
{challengeId && (
<>
<input value={otp} onChange={(e) => setOtp(e.target.value)} maxLength={6} />
<button onClick={verify} disabled={auth.loading || otp.length !== 6}>Verify</button>
</>
)}
{auth.error && <p style={{ color: 'red' }}>{auth.error.message}</p>}
</div>
);
}
Google + Kakao
Google — obtain id_token via Google Identity Services (client-side), forward:
await auth.signInWithGoogle(googleIdToken);
Kakao — user goes to Kakao consent, comes back with ?code=:
const code = new URLSearchParams(location.search).get('code')!;
await auth.signInWithKakao({ code, redirect_uri: window.location.origin + '/kakao/callback' });
Framework-agnostic (Vue, Svelte, vanilla)
Skip the hooks, use the clients directly:
import { PafiAuthClient, PafiIssuerClient } from '@pafi-dev/issuer-fe';
const auth = new PafiAuthClient({ baseUrl: PAFI_AUTH_BASE });
const user = await auth.emailVerify({ email, otp, challenge_id });
const issuer = new PafiIssuerClient({
baseUrl: ISSUER_API,
getAccessToken: async () => currentSessionToken,
});
const { txHash } = await issuer.claim({ amount: '100000000000000000000' });
Error handling
| Error | Where | Meaning |
|---|---|---|
PafiAuthError (401 invalid_client) |
usePafiAuth |
pafi-auth or gateway rejected credentials |
PafiAuthError (400 otp_expired) |
usePafiAuth.verifyEmailOtp |
5-min OTP window elapsed |
PafiAuthError (400 invalid_oauth_token) |
Google/Kakao flows | id_token / code expired or client_id mismatch |
PafiIssuerHttpError (400 insufficient_balance) |
usePafiIssuer.claim |
Off-chain ledger balance < amount |
PafiIssuerHttpError (401) |
any issuer call | Session token expired — refresh + retry |
Development
pnpm install
pnpm --filter @pafi-dev/issuer-fe build
pnpm --filter @pafi-dev/issuer-fe test
License
UNLICENSED — internal PAFI SDK.