npm.io
0.3.0 • Published yesterday

@pafi-dev/issuer-fe

Licence
Version
0.3.0
Deps
1
Size
158 kB
Vulns
0
Weekly
0

@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-auth on the BE (session token minting)
  • @pafi-dev/issuer/mint-burn on 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.