Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions examples/privy-next-yield-demo/src/app/api/claim/route.ts
Original file line number Diff line number Diff line change
@@ -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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can't we pull now from the claim response? like this should be created_at

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooh this is a good callout - the yield claim API doesn't have created_at and updated_at like deposit and withdraw yet. But it should be super easy to add because those fields are available in the CockroachDB transaction.

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 }
);
}
}
4 changes: 2 additions & 2 deletions examples/privy-next-yield-demo/src/app/api/deposit/route.ts
Original file line number Diff line number Diff line change
@@ -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!;
Expand Down Expand Up @@ -39,7 +39,7 @@ export async function POST(request: NextRequest) {
);
}

upsertTransaction(data);
upsertTransaction({ ...data, ...USDC_TOKEN });

return NextResponse.json(data);
} catch (error) {
Expand Down
2 changes: 2 additions & 0 deletions examples/privy-next-yield-demo/src/app/api/webhook/route.ts
Original file line number Diff line number Diff line change
@@ -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!;
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions examples/privy-next-yield-demo/src/app/api/withdraw/route.ts
Original file line number Diff line number Diff line change
@@ -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!;
Expand Down Expand Up @@ -39,7 +39,7 @@ export async function POST(request: NextRequest) {
);
}

upsertTransaction(data);
upsertTransaction({ ...data, ...USDC_TOKEN });

return NextResponse.json(data);
} catch (error) {
Expand Down
2 changes: 2 additions & 0 deletions examples/privy-next-yield-demo/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -48,6 +49,7 @@ export default function Dashboard() {
<WalletCard />
<DepositForm onSuccess={handleTransactionSuccess} />
<WithdrawForm onSuccess={handleTransactionSuccess} />
<ClaimRewardsForm onSuccess={handleTransactionSuccess} />
</div>

{/* Right Column - Position & Vault Info */}
Expand Down
150 changes: 150 additions & 0 deletions examples/privy-next-yield-demo/src/components/ClaimRewardsForm.tsx
Original file line number Diff line number Diff line change
@@ -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<ClaimStatus>('idle');
const [error, setError] = useState<string | null>(null);
const [claimResult, setClaimResult] = useState<ClaimResponse | null>(null);

const embeddedWallet = wallets.find((wallet) => wallet.walletClientType === 'privy');
const privyWalletId = user?.linkedAccounts?.find(
(a): a is Extract<typeof a, { type: 'wallet' }> =>
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 (
<div className="rounded-2xl p-6 bg-white border border-[#E2E3F0]">
<h3 className="text-lg font-semibold text-[#040217] mb-4">Claim Rewards</h3>

{status === 'no_rewards' && (
<p className="text-sm text-[#906218] bg-[#FEF3C7] p-3 rounded-lg mb-4">
No rewards available to claim yet. Rewards accrue over time from vault participation.
</p>
)}

{error && (
<p className="text-sm text-[#991B1B] bg-[#FEE2E2] p-3 rounded-lg mb-4">
{error}
</p>
)}

{status === 'success' && claimResult && (
<div className="bg-[#DCFCE7] p-3 rounded-lg mb-4">
<p className="text-sm text-[#135638] font-medium">
Claimed {claimResult.rewards.map((r, i) => (
<span key={r.token_address}>
{i > 0 && ', '}
{formatTokenAmount(r.amount, 18, 4)} {r.token_symbol}
</span>
))}
</p>
<p className="text-xs text-[#135638] mt-1">
Status: {claimResult.status}
</p>
</div>
)}

<button
type="button"
onClick={handleClaim}
disabled={isDisabled}
className="button-primary w-full rounded-full"
>
{status === 'loading' ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Claiming...
</span>
) : (
'Claim Rewards'
)}
</button>

<p className="text-xs text-[#9498B8] mt-4 text-center">
Claim incentive rewards earned from vault participation
</p>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -137,7 +139,7 @@ export function TransactionHistory({
Transaction history
</h3>
<p className="text-sm text-[#64668B]">
No transactions yet. Make a deposit or withdrawal to get started.
No transactions yet. Make a deposit, withdrawal, or claim to get started.
</p>
</div>
);
Expand All @@ -151,7 +153,13 @@ export function TransactionHistory({

<div>
{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 (
<div
Expand All @@ -162,14 +170,15 @@ export function TransactionHistory({
: ""
}`}
>
<span className="text-sm font-semibold text-[#040217]">
{isDeposit ? "Deposited" : "Withdrew"} $
{formatUSDC(tx.asset_amount)} USDC
<span className="text-sm font-semibold text-[#040217] w-2/5 truncate">
{label}
</span>
<span className="text-sm text-[#64668B]">
<span className="text-sm text-[#64668B] w-2/5 text-center">
{formatTimestamp(tx.created_at)}
</span>
<StatusBadge status={tx.status} />
<span className="w-1/5 text-right">
<StatusBadge status={tx.status} />
</span>
</div>
);
})}
Expand Down
Loading