@pafi-dev/issuer-fe
v0.4.0
Published
React SDK for partner issuer web frontends. Ships a typed HTTP client + hooks for the issuer BE's mint (`/claim`) and burn (`/redeem`) endpoints, user profile/history, and an on-chain balance reader (viem). The issuer FE never handles Privy tokens or user
Readme
@pafi-dev/issuer-fe
React SDK for partner issuer web frontends integrating with their own issuer backend.
Pairs with:
@pafi-dev/issuer/pregenon the BE (wallet provisioning by verified email)@pafi-dev/issuer/mint-burnon the BE (on-chain mint/burn signing)
Login is yours. This SDK no longer ships auth. The PAFI gateway retired its user-authentication surface, so the issuer authenticates its own users, calls
POST /v1/wallets/pregenserver-side to get the user's wallet address, and mints PT directly to it. Removed here:usePafiAuth,PafiAuthClient,DirectAuthSuccess,DirectAuthError,EmailStartResponse,PafiAuthError.
What you get
| Hook / Class | Purpose |
|---|---|
| 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.
No login — the issuer owns user authentication end to end.
Issuer FEs are display + trigger, never signers.
Install
pnpm add @pafi-dev/issuer-fe viemPeer deps: react (18 or 19), viem (2.x).
Quickstart — claim → balance flow
import { useMemo } from 'react';
import { createPublicClient, http } from 'viem';
import { arbitrum } from 'viem/chains';
import {
usePafiIssuer,
usePafiBalance,
formatBalance,
} from '@pafi-dev/issuer-fe';
const ISSUER_API = 'https://gg56-api-stg.pacificfinance.org';
const PT = '0x26Ca5e61458dF9F8C89c117F92192C31C63b042A';
export function App({ session }: { session: IssuerSession | null }) {
// `session` comes from YOUR login flow. The issuer BE returns the
// wallet address it got from `POST /v1/wallets/pregen`.
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: session?.walletAddress,
tokens: [
{ key: 'PT', address: PT, decimals: 18 },
{ key: 'ETH', address: null, decimals: 18 },
],
});
if (!session) return <YourOwnLoginForm />;
return (
<div>
<p>Signed in as {session.email}</p>
<p>Wallet: <code>{session.walletAddress}</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>
)}
</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 };
}
}Framework-agnostic (Vue, Svelte, vanilla)
Skip the hooks, use the clients directly:
import { PafiIssuerClient } from '@pafi-dev/issuer-fe';
const issuer = new PafiIssuerClient({
baseUrl: ISSUER_API,
getAccessToken: async () => currentSessionToken,
});
const { txHash } = await issuer.claim({ amount: '100000000000000000000' });Error handling
| Error | Where | Meaning |
|---|---|---|
| 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 testLicense
UNLICENSED — internal PAFI SDK.
