diff --git a/examples/privy-next-yield-demo/src/app/api/claim/route.ts b/examples/privy-next-yield-demo/src/app/api/claim/route.ts new file mode 100644 index 0000000..8dc6279 --- /dev/null +++ b/examples/privy-next-yield-demo/src/app/api/claim/route.ts @@ -0,0 +1,99 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { PRIVY_API_URL } from '@/lib/constants'; +import { upsertTransaction } from '@/lib/transaction-store'; + +const PRIVY_APP_ID = process.env.NEXT_PUBLIC_PRIVY_APP_ID!; +const PRIVY_APP_SECRET = process.env.PRIVY_APP_SECRET!; + +// The claim endpoint returns a broader set of transaction statuses than +// deposit/withdraw. Normalize them to the three values used by deposit and withdraw. +function normalizeClaimStatus(status: string): 'pending' | 'confirmed' | 'failed' { + switch (status) { + case 'pending': + case 'broadcasted': + return 'pending'; + case 'confirmed': + case 'finalized': + return 'confirmed'; + case 'execution_reverted': + case 'failed': + case 'replaced': + case 'provider_error': + return 'failed'; + default: + return 'pending'; + } +} + +export async function POST(request: NextRequest) { + try { + const { wallet_id, caip2, authorization_signature } = await request.json(); + + if (!wallet_id || !caip2) { + return NextResponse.json( + { error: 'Missing required fields: wallet_id, caip2' }, + { status: 400 } + ); + } + + const response = await fetch( + `${PRIVY_API_URL}/wallets/${wallet_id}/ethereum_yield_claim`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'privy-app-id': PRIVY_APP_ID, + 'Authorization': `Basic ${Buffer.from(`${PRIVY_APP_ID}:${PRIVY_APP_SECRET}`).toString('base64')}`, + ...(authorization_signature ? { 'privy-authorization-signature': authorization_signature } : {}), + }, + body: JSON.stringify({ caip2 }), + } + ); + + const data = await response.json(); + + if (!response.ok) { + const errorMessage = data.error || data.message || ''; + const isNoRewards = + response.status === 400 && + errorMessage.includes('No claimable rewards found'); + + if (isNoRewards) { + return NextResponse.json( + { error: errorMessage, code: 'no_rewards' }, + { status: 400 } + ); + } + + return NextResponse.json( + { error: errorMessage || 'Claim failed' }, + { status: response.status } + ); + } + + // Create one transaction per reward token + const now = Date.now(); + for (let i = 0; i < data.rewards.length; i++) { + const reward = data.rewards[i]; + upsertTransaction({ + id: `${data.id}-${i}`, + wallet_id, + type: 'claim', + status: normalizeClaimStatus(data.status), + asset_amount: reward.amount, + token_symbol: reward.token_symbol, + token_decimals: 18, + created_at: now, + updated_at: now, + }); + } + + return NextResponse.json(data); + } catch (error) { + console.error('Claim error:', error); + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/examples/privy-next-yield-demo/src/app/api/deposit/route.ts b/examples/privy-next-yield-demo/src/app/api/deposit/route.ts index 621556f..6486337 100644 --- a/examples/privy-next-yield-demo/src/app/api/deposit/route.ts +++ b/examples/privy-next-yield-demo/src/app/api/deposit/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { PRIVY_API_URL } from '@/lib/constants'; +import { PRIVY_API_URL, USDC_TOKEN } from '@/lib/constants'; import { upsertTransaction } from '@/lib/transaction-store'; const PRIVY_APP_ID = process.env.NEXT_PUBLIC_PRIVY_APP_ID!; @@ -39,7 +39,7 @@ export async function POST(request: NextRequest) { ); } - upsertTransaction(data); + upsertTransaction({ ...data, ...USDC_TOKEN }); return NextResponse.json(data); } catch (error) { diff --git a/examples/privy-next-yield-demo/src/app/api/webhook/route.ts b/examples/privy-next-yield-demo/src/app/api/webhook/route.ts index 619c1cf..88e6659 100644 --- a/examples/privy-next-yield-demo/src/app/api/webhook/route.ts +++ b/examples/privy-next-yield-demo/src/app/api/webhook/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { PrivyClient } from '@privy-io/server-auth'; import { upsertTransaction } from '@/lib/transaction-store'; +import { USDC_TOKEN } from '@/lib/constants'; const PRIVY_APP_ID = process.env.NEXT_PUBLIC_PRIVY_APP_ID!; const PRIVY_APP_SECRET = process.env.PRIVY_APP_SECRET!; @@ -53,6 +54,7 @@ export async function POST(request: NextRequest) { type: String(txData.type ?? ''), status: String(txData.status ?? ''), asset_amount: String(txData.asset_amount ?? '0'), + ...USDC_TOKEN, share_amount: txData.share_amount ? String(txData.share_amount) : undefined, transaction_id: txData.transaction_id ? String(txData.transaction_id) : undefined, approval_transaction_id: txData.approval_transaction_id ? String(txData.approval_transaction_id) : undefined, diff --git a/examples/privy-next-yield-demo/src/app/api/withdraw/route.ts b/examples/privy-next-yield-demo/src/app/api/withdraw/route.ts index d643ce6..7986973 100644 --- a/examples/privy-next-yield-demo/src/app/api/withdraw/route.ts +++ b/examples/privy-next-yield-demo/src/app/api/withdraw/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { PRIVY_API_URL } from '@/lib/constants'; +import { PRIVY_API_URL, USDC_TOKEN } from '@/lib/constants'; import { upsertTransaction } from '@/lib/transaction-store'; const PRIVY_APP_ID = process.env.NEXT_PUBLIC_PRIVY_APP_ID!; @@ -39,7 +39,7 @@ export async function POST(request: NextRequest) { ); } - upsertTransaction(data); + upsertTransaction({ ...data, ...USDC_TOKEN }); return NextResponse.json(data); } catch (error) { diff --git a/examples/privy-next-yield-demo/src/app/dashboard/page.tsx b/examples/privy-next-yield-demo/src/app/dashboard/page.tsx index 0eec22d..ac95410 100644 --- a/examples/privy-next-yield-demo/src/app/dashboard/page.tsx +++ b/examples/privy-next-yield-demo/src/app/dashboard/page.tsx @@ -7,6 +7,7 @@ import { ToastContainer } from "react-toastify"; import { WalletCard } from "@/components/WalletCard"; import { DepositForm } from "@/components/DepositForm"; import { WithdrawForm } from "@/components/WithdrawForm"; +import { ClaimRewardsForm } from "@/components/ClaimRewardsForm"; import { PositionDisplay } from "@/components/PositionDisplay"; import { FeeRecipientCard } from "@/components/FeeRecipientCard"; import { TransactionHistory } from "@/components/TransactionHistory"; @@ -48,6 +49,7 @@ export default function Dashboard() { + {/* Right Column - Position & Vault Info */} diff --git a/examples/privy-next-yield-demo/src/components/ClaimRewardsForm.tsx b/examples/privy-next-yield-demo/src/components/ClaimRewardsForm.tsx new file mode 100644 index 0000000..1df9f15 --- /dev/null +++ b/examples/privy-next-yield-demo/src/components/ClaimRewardsForm.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { useState } from 'react'; +import { usePrivy, useWallets, useAuthorizationSignature } from '@privy-io/react-auth'; +import { PRIVY_API_URL, formatTokenAmount } from '@/lib/constants'; + +type ClaimStatus = 'idle' | 'loading' | 'success' | 'error' | 'no_rewards'; + +interface ClaimReward { + token_address: string; + token_symbol: string; + amount: string; +} + +interface ClaimResponse { + id: string; + caip2: string; + status: string; + rewards: ClaimReward[]; +} + +export function ClaimRewardsForm({ onSuccess }: { onSuccess?: () => void }) { + const { user } = usePrivy(); + const { wallets } = useWallets(); + const { generateAuthorizationSignature } = useAuthorizationSignature(); + const [status, setStatus] = useState('idle'); + const [error, setError] = useState(null); + const [claimResult, setClaimResult] = useState(null); + + const embeddedWallet = wallets.find((wallet) => wallet.walletClientType === 'privy'); + const privyWalletId = user?.linkedAccounts?.find( + (a): a is Extract => + a.type === 'wallet' && 'walletClientType' in a && a.walletClientType === 'privy' + )?.id; + const appId = process.env.NEXT_PUBLIC_PRIVY_APP_ID!; + + const handleClaim = async () => { + if (!embeddedWallet || !privyWalletId) { + setError('Wallet not available'); + return; + } + + setStatus('loading'); + setError(null); + setClaimResult(null); + + try { + const caip2 = 'eip155:8453'; + const url = `${PRIVY_API_URL}/wallets/${privyWalletId}/ethereum_yield_claim`; + const body = { caip2 }; + + const { signature: authorizationSignature } = await generateAuthorizationSignature({ + version: 1, + method: 'POST', + url, + body, + headers: { 'privy-app-id': appId }, + }); + + const response = await fetch('/api/claim', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + wallet_id: privyWalletId, + caip2, + authorization_signature: authorizationSignature, + }), + }); + + const data = await response.json(); + + if (!response.ok) { + if (data.code === 'no_rewards') { + setStatus('no_rewards'); + return; + } + throw new Error(data.error || 'Claim failed'); + } + + setClaimResult(data); + setStatus('success'); + onSuccess?.(); + } catch (err) { + console.error('Claim error:', err); + setError(err instanceof Error ? err.message : 'Claim failed'); + setStatus('error'); + } + }; + + const isDisabled = status === 'loading' || !embeddedWallet; + + return ( +
+

Claim Rewards

+ + {status === 'no_rewards' && ( +

+ No rewards available to claim yet. Rewards accrue over time from vault participation. +

+ )} + + {error && ( +

+ {error} +

+ )} + + {status === 'success' && claimResult && ( +
+

+ Claimed {claimResult.rewards.map((r, i) => ( + + {i > 0 && ', '} + {formatTokenAmount(r.amount, 18, 4)} {r.token_symbol} + + ))} +

+

+ Status: {claimResult.status} +

+
+ )} + + + +

+ Claim incentive rewards earned from vault participation +

+
+ ); +} diff --git a/examples/privy-next-yield-demo/src/components/TransactionHistory.tsx b/examples/privy-next-yield-demo/src/components/TransactionHistory.tsx index 9149e10..a6d9a5f 100644 --- a/examples/privy-next-yield-demo/src/components/TransactionHistory.tsx +++ b/examples/privy-next-yield-demo/src/components/TransactionHistory.tsx @@ -2,15 +2,17 @@ import { useEffect, useState, useCallback } from "react"; import { usePrivy } from "@privy-io/react-auth"; -import { formatUSDC } from "@/lib/constants"; +import { formatUSDC, formatTokenAmount } from "@/lib/constants"; interface Transaction { id: string; wallet_id: string; - vault_id: string; + vault_id?: string; type: string; status: string; asset_amount: string; + token_symbol: string; + token_decimals: number; share_amount?: string; transaction_id?: string; created_at: number; @@ -137,7 +139,7 @@ export function TransactionHistory({ Transaction history

- No transactions yet. Make a deposit or withdrawal to get started. + No transactions yet. Make a deposit, withdrawal, or claim to get started.

); @@ -151,7 +153,13 @@ export function TransactionHistory({
{transactions.map((tx, index) => { - const isDeposit = tx.type === "deposit"; + let label: string; + if (tx.type === "claim") { + label = `Claimed ${formatTokenAmount(tx.asset_amount, tx.token_decimals, 4)} ${tx.token_symbol}`; + } else { + const verb = tx.type === "deposit" ? "Deposited" : "Withdrew"; + label = `${verb} $${formatUSDC(tx.asset_amount)} USDC`; + } return (
- - {isDeposit ? "Deposited" : "Withdrew"} $ - {formatUSDC(tx.asset_amount)} USDC + + {label} - + {formatTimestamp(tx.created_at)} - + + +
); })} diff --git a/examples/privy-next-yield-demo/src/lib/constants.ts b/examples/privy-next-yield-demo/src/lib/constants.ts index b2bac02..4a82553 100644 --- a/examples/privy-next-yield-demo/src/lib/constants.ts +++ b/examples/privy-next-yield-demo/src/lib/constants.ts @@ -3,8 +3,9 @@ import { base } from 'viem/chains'; // Network Configuration export const CHAIN = base; // USDC on Base Mainnet -export const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' as const; +export const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; export const USDC_DECIMALS = 6; +export const USDC_TOKEN = { token_symbol: 'USDC', token_decimals: USDC_DECIMALS }; // Privy API — must match the URL used for authorization signature signing export const PRIVY_API_URL = 'https://api.privy.io/v1'; @@ -15,11 +16,7 @@ export const getFeeRecipientWalletId = () => process.env.NEXT_PUBLIC_FEE_RECIPIE // Format USDC amount for display (from smallest unit to human readable) export function formatUSDC(amount: string | bigint): string { - const value = typeof amount === 'string' ? BigInt(amount) : amount; - const wholePart = value / BigInt(10 ** USDC_DECIMALS); - const fractionalPart = value % BigInt(10 ** USDC_DECIMALS); - const fractionalStr = fractionalPart.toString().padStart(USDC_DECIMALS, '0'); - return `${wholePart}.${fractionalStr.slice(0, 2)}`; + return formatTokenAmount(amount, USDC_DECIMALS); } // Parse human readable USDC to smallest unit (wei equivalent) @@ -29,6 +26,24 @@ export function parseUSDC(amount: string): string { return `${whole}${paddedDecimal}`; } +// MORPHO on Base Mainnet +export const MORPHO_ADDRESS = '0xBAa5CC21fd487B8Fcc2F632f3F4E8D37262a0842'; +export const MORPHO_DECIMALS = 18; + +// Format any token amount for display (from smallest unit to human readable) +export function formatTokenAmount( + amount: string | bigint, + decimals: number, + displayDecimals: number = 2 +): string { + const value = typeof amount === 'string' ? BigInt(amount) : amount; + const divisor = BigInt(10 ** decimals); + const wholePart = value / divisor; + const fractionalPart = value % divisor; + const fractionalStr = fractionalPart.toString().padStart(decimals, '0'); + return `${wholePart}.${fractionalStr.slice(0, displayDecimals)}`; +} + // Truncate address for display export function truncateAddress(address: string): string { if (!address) return ''; diff --git a/examples/privy-next-yield-demo/src/lib/transaction-store.ts b/examples/privy-next-yield-demo/src/lib/transaction-store.ts index 0310b4a..25db693 100644 --- a/examples/privy-next-yield-demo/src/lib/transaction-store.ts +++ b/examples/privy-next-yield-demo/src/lib/transaction-store.ts @@ -8,10 +8,12 @@ export interface Transaction { id: string; wallet_id: string; - vault_id: string; - type: string; // "deposit" | "withdraw" + vault_id?: string; + type: string; // "deposit" | "withdraw" | "claim" status: string; // "pending" | "confirmed" | "failed" asset_amount: string; + token_symbol: string; // e.g. "USDC", "MORPHO" + token_decimals: number; // e.g. 6 for USDC, 18 for MORPHO share_amount?: string; transaction_id?: string; approval_transaction_id?: string;