diff --git a/wallet-earn/app/api/deposit/execute/route.ts b/wallet-earn/app/api/deposit/execute/route.ts new file mode 100644 index 00000000..7b586745 --- /dev/null +++ b/wallet-earn/app/api/deposit/execute/route.ts @@ -0,0 +1,93 @@ +import { CHAIN } from "@/utils/constants"; +import { CompassApiSDK } from "@compass-labs/api-sdk"; +import { type UnsignedTransaction } from "@compass-labs/api-sdk/models/components"; +import { createPublicClient, createWalletClient, http } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { base } from "viem/chains"; + +export async function POST(request: Request) { + try { + const { owner, eip712, signature } = await request.json(); + + if (!owner || !eip712 || !signature) { + return Response.json( + { error: "Missing required parameters (owner, eip712, signature)" }, + { status: 400 } + ); + } + + // Sponsor account - pays gas and submits transaction + const sponsorAccount = privateKeyToAccount( + process.env.GAS_SPONSOR_PK as `0x${string}` + ); + + const sponsorWalletClient = createWalletClient({ + account: sponsorAccount, + chain: base, + transport: http(process.env.RPC_URL), + }); + + const publicClient = createPublicClient({ + chain: base, + transport: http(process.env.RPC_URL), + }); + + const compassApiSDK = new CompassApiSDK({ + apiKeyAuth: process.env.COMPASS_API_KEY, + }); + + // Prepare gas-sponsored transaction with user's signature + const sponsorGasResponse = await compassApiSDK.gasSponsorship.gasSponsorshipPrepare({ + owner, + chain: CHAIN, + eip712: eip712 as any, + signature, + sender: sponsorAccount.address, + }); + + const sponsoredTransaction = sponsorGasResponse.transaction as UnsignedTransaction; + + if (!sponsoredTransaction) { + return Response.json( + { error: "No transaction returned from gasSponsorshipPrepare" }, + { status: 500 } + ); + } + + // Sponsor signs and submits the transaction + const depositTxHash = await sponsorWalletClient.sendTransaction({ + ...(sponsoredTransaction as any), + value: BigInt(sponsoredTransaction.value), + gas: sponsoredTransaction.gas ? BigInt(sponsoredTransaction.gas) : undefined, + maxFeePerGas: BigInt(sponsoredTransaction.maxFeePerGas), + maxPriorityFeePerGas: BigInt(sponsoredTransaction.maxPriorityFeePerGas), + }); + + const tx = await publicClient.waitForTransactionReceipt({ + hash: depositTxHash, + }); + + if (tx.status !== "success") { + return Response.json( + { error: "Deposit transaction reverted" }, + { status: 500 } + ); + } + + return Response.json({ + success: true, + txHash: depositTxHash, + }); + } catch (error) { + console.error("Error executing deposit:", error); + + if (error instanceof Error) { + return Response.json({ error: error.message }, { status: 400 }); + } + + return Response.json( + { error: "Failed to execute deposit" }, + { status: 500 } + ); + } +} diff --git a/wallet-earn/app/api/deposit/prepare/route.ts b/wallet-earn/app/api/deposit/prepare/route.ts new file mode 100644 index 00000000..3effc90b --- /dev/null +++ b/wallet-earn/app/api/deposit/prepare/route.ts @@ -0,0 +1,66 @@ +import { CHAIN } from "@/utils/constants"; +import { CompassApiSDK } from "@compass-labs/api-sdk"; + +export async function POST(request: Request) { + try { + const { vaultAddress, amount, token, owner } = await request.json(); + + if (!vaultAddress || !amount || !token || !owner) { + return Response.json( + { error: "Missing required parameters" }, + { status: 400 } + ); + } + + const compassApiSDK = new CompassApiSDK({ + apiKeyAuth: process.env.COMPASS_API_KEY, + }); + + // Call earnManage with gas sponsorship enabled + const deposit = await compassApiSDK.earn.earnManage({ + owner, + chain: CHAIN, + venue: { + type: "VAULT", + vaultAddress, + }, + action: "DEPOSIT", + amount, + gasSponsorship: true, + }); + + const eip712TypedData = deposit.eip712; + + if (!eip712TypedData) { + return Response.json( + { error: "No EIP-712 typed data returned from earnManage" }, + { status: 500 } + ); + } + + // Normalize types for viem compatibility + // SDK returns camelCase keys (safeTx, eip712Domain) but primaryType as "SafeTx" + const normalizedTypes = { + EIP712Domain: (eip712TypedData.types as any).eip712Domain, + SafeTx: (eip712TypedData.types as any).safeTx, + }; + + return Response.json({ + eip712: eip712TypedData, + normalizedTypes, + domain: eip712TypedData.domain, + message: eip712TypedData.message, + }); + } catch (error) { + console.error("Error preparing deposit:", error); + + if (error instanceof Error) { + return Response.json({ error: error.message }, { status: 400 }); + } + + return Response.json( + { error: "Failed to prepare deposit" }, + { status: 500 } + ); + } +} diff --git a/wallet-earn/app/api/earn-account/check/route.ts b/wallet-earn/app/api/earn-account/check/route.ts new file mode 100644 index 00000000..dda8c6f5 --- /dev/null +++ b/wallet-earn/app/api/earn-account/check/route.ts @@ -0,0 +1,97 @@ +import { CHAIN } from "@/utils/constants"; +import { CompassApiSDK } from "@compass-labs/api-sdk"; +import { createPublicClient, http } from "viem"; +import { base } from "viem/chains"; + +const publicClient = createPublicClient({ + chain: base, + transport: http(process.env.RPC_URL), +}); + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const owner = searchParams.get("owner"); + + if (!owner) { + return Response.json( + { error: "Missing owner address" }, + { status: 400 } + ); + } + + const compassApiSDK = new CompassApiSDK({ + apiKeyAuth: process.env.COMPASS_API_KEY, + }); + + // Try to create account - if it returns 400, account already exists + try { + const response = await compassApiSDK.earn.earnCreateAccount({ + chain: CHAIN, + owner, + sender: owner, + estimateGas: false, + }); + + const earnAccountAddress = response.earnAccountAddress; + + // Check if account is already deployed by checking bytecode + const bytecode = await publicClient.getCode({ + address: earnAccountAddress as `0x${string}`, + }); + + const isDeployed = bytecode !== undefined && bytecode !== "0x"; + + return Response.json({ + earnAccountAddress, + isDeployed, + }); + } catch (sdkError: any) { + // If SDK returns 400, account already exists - this means it's deployed + // Try to extract the earn account address from the error or response + console.log("SDK error checking earn account:", sdkError); + + // Check if it's a 400 error (account already exists) + const statusCode = sdkError?.statusCode || sdkError?.status; + if (statusCode === 400) { + // Account exists - try to get the address from the error body + const errorBody = sdkError?.body || sdkError?.rawResponse; + let earnAccountAddress = null; + + // Try to parse earn_account_address from error response + if (typeof errorBody === "string") { + try { + const parsed = JSON.parse(errorBody); + earnAccountAddress = parsed.earn_account_address || parsed.earnAccountAddress; + } catch { + // Ignore parse errors + } + } else if (errorBody?.earn_account_address) { + earnAccountAddress = errorBody.earn_account_address; + } + + return Response.json({ + earnAccountAddress, + isDeployed: true, // 400 means account already exists + }); + } + + return Response.json({ + earnAccountAddress: null, + isDeployed: false, + error: "Could not determine earn account status", + }); + } + } catch (error) { + console.error("Error checking earn account:", error); + + if (error instanceof Error) { + return Response.json({ error: error.message }, { status: 400 }); + } + + return Response.json( + { error: "Failed to check earn account" }, + { status: 500 } + ); + } +} diff --git a/wallet-earn/app/api/earn-account/create/route.ts b/wallet-earn/app/api/earn-account/create/route.ts new file mode 100644 index 00000000..654f5278 --- /dev/null +++ b/wallet-earn/app/api/earn-account/create/route.ts @@ -0,0 +1,44 @@ +import { CHAIN } from "@/utils/constants"; +import { CompassApiSDK } from "@compass-labs/api-sdk"; + +export async function POST(request: Request) { + try { + const { owner } = await request.json(); + + if (!owner) { + return Response.json( + { error: "Missing owner address" }, + { status: 400 } + ); + } + + const compassApiSDK = new CompassApiSDK({ + apiKeyAuth: process.env.COMPASS_API_KEY, + }); + + // Call Compass API to create earn account + // The owner will also be the sender (they sign and submit the tx) + const response = await compassApiSDK.earn.earnCreateAccount({ + chain: CHAIN, + owner, + sender: owner, // Owner sends the transaction themselves + estimateGas: true, + }); + + return Response.json({ + transaction: response.transaction, + earnAccountAddress: response.earnAccountAddress, + }); + } catch (error) { + console.error("Error creating earn account:", error); + + if (error instanceof Error) { + return Response.json({ error: error.message }, { status: 400 }); + } + + return Response.json( + { error: "Failed to create earn account" }, + { status: 500 } + ); + } +} diff --git a/wallet-earn/app/api/positions/route.ts b/wallet-earn/app/api/positions/route.ts index e74814fd..e458517f 100644 --- a/wallet-earn/app/api/positions/route.ts +++ b/wallet-earn/app/api/positions/route.ts @@ -1,10 +1,17 @@ import { CHAIN } from "@/utils/constants"; -import { getWalletAddress } from "@/utils/utils"; import { CompassApiSDK } from "@compass-labs/api-sdk"; -export async function GET() { +export async function GET(request: Request) { try { - const walletAddress = getWalletAddress(); + const { searchParams } = new URL(request.url); + const walletAddress = searchParams.get("wallet"); + + if (!walletAddress) { + return new Response( + JSON.stringify({ error: "Missing wallet address" }), + { status: 400 } + ); + } const compassApiSDK = new CompassApiSDK({ apiKeyAuth: process.env.COMPASS_API_KEY, diff --git a/wallet-earn/app/api/token/[token]/route.ts b/wallet-earn/app/api/token/[token]/route.ts index 3f62d91e..42f809dc 100644 --- a/wallet-earn/app/api/token/[token]/route.ts +++ b/wallet-earn/app/api/token/[token]/route.ts @@ -1,14 +1,20 @@ import { CHAIN } from "@/utils/constants"; -import { getWalletAddress } from "@/utils/utils"; import { CompassApiSDK } from "@compass-labs/api-sdk"; export async function GET( - _: Request, + request: Request, { params }: { params: Promise<{ token: string }> } ) { const { token } = await params; - - const walletAddress = getWalletAddress(); + const { searchParams } = new URL(request.url); + const walletAddress = searchParams.get("wallet"); + + if (!walletAddress) { + return new Response( + JSON.stringify({ error: "Missing wallet address" }), + { status: 400 } + ); + } const compassApiSDK = new CompassApiSDK({ apiKeyAuth: process.env.COMPASS_API_KEY, diff --git a/wallet-earn/app/api/withdraw/execute/route.ts b/wallet-earn/app/api/withdraw/execute/route.ts new file mode 100644 index 00000000..80688ddc --- /dev/null +++ b/wallet-earn/app/api/withdraw/execute/route.ts @@ -0,0 +1,93 @@ +import { CHAIN } from "@/utils/constants"; +import { CompassApiSDK } from "@compass-labs/api-sdk"; +import { type UnsignedTransaction } from "@compass-labs/api-sdk/models/components"; +import { createPublicClient, createWalletClient, http } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { base } from "viem/chains"; + +export async function POST(request: Request) { + try { + const { owner, eip712, signature } = await request.json(); + + if (!owner || !eip712 || !signature) { + return Response.json( + { error: "Missing required parameters (owner, eip712, signature)" }, + { status: 400 } + ); + } + + // Sponsor account - pays gas and submits transaction + const sponsorAccount = privateKeyToAccount( + process.env.GAS_SPONSOR_PK as `0x${string}` + ); + + const sponsorWalletClient = createWalletClient({ + account: sponsorAccount, + chain: base, + transport: http(process.env.RPC_URL), + }); + + const publicClient = createPublicClient({ + chain: base, + transport: http(process.env.RPC_URL), + }); + + const compassApiSDK = new CompassApiSDK({ + apiKeyAuth: process.env.COMPASS_API_KEY, + }); + + // Prepare gas-sponsored transaction with user's signature + const sponsorGasResponse = await compassApiSDK.gasSponsorship.gasSponsorshipPrepare({ + owner, + chain: CHAIN, + eip712: eip712 as any, + signature, + sender: sponsorAccount.address, + }); + + const sponsoredTransaction = sponsorGasResponse.transaction as UnsignedTransaction; + + if (!sponsoredTransaction) { + return Response.json( + { error: "No transaction returned from gasSponsorshipPrepare" }, + { status: 500 } + ); + } + + // Sponsor signs and submits the transaction + const withdrawTxHash = await sponsorWalletClient.sendTransaction({ + ...(sponsoredTransaction as any), + value: BigInt(sponsoredTransaction.value), + gas: sponsoredTransaction.gas ? BigInt(sponsoredTransaction.gas) : undefined, + maxFeePerGas: BigInt(sponsoredTransaction.maxFeePerGas), + maxPriorityFeePerGas: BigInt(sponsoredTransaction.maxPriorityFeePerGas), + }); + + const tx = await publicClient.waitForTransactionReceipt({ + hash: withdrawTxHash, + }); + + if (tx.status !== "success") { + return Response.json( + { error: "Withdraw transaction reverted" }, + { status: 500 } + ); + } + + return Response.json({ + success: true, + txHash: withdrawTxHash, + }); + } catch (error) { + console.error("Error executing withdraw:", error); + + if (error instanceof Error) { + return Response.json({ error: error.message }, { status: 400 }); + } + + return Response.json( + { error: "Failed to execute withdraw" }, + { status: 500 } + ); + } +} diff --git a/wallet-earn/app/api/withdraw/prepare/route.ts b/wallet-earn/app/api/withdraw/prepare/route.ts new file mode 100644 index 00000000..d4e9fab7 --- /dev/null +++ b/wallet-earn/app/api/withdraw/prepare/route.ts @@ -0,0 +1,65 @@ +import { CHAIN } from "@/utils/constants"; +import { CompassApiSDK } from "@compass-labs/api-sdk"; + +export async function POST(request: Request) { + try { + const { vaultAddress, amount, token, owner, isAll } = await request.json(); + + if (!vaultAddress || !amount || !token || !owner || typeof isAll !== "boolean") { + return Response.json( + { error: "Missing required parameters" }, + { status: 400 } + ); + } + + const compassApiSDK = new CompassApiSDK({ + apiKeyAuth: process.env.COMPASS_API_KEY, + }); + + // Call earnManage with gas sponsorship enabled + const withdraw = await compassApiSDK.earn.earnManage({ + owner, + chain: CHAIN, + venue: { + type: "VAULT", + vaultAddress, + }, + action: "WITHDRAW", + amount: isAll ? "ALL" : amount, + gasSponsorship: true, + }); + + const eip712TypedData = withdraw.eip712; + + if (!eip712TypedData) { + return Response.json( + { error: "No EIP-712 typed data returned from earnManage" }, + { status: 500 } + ); + } + + // Normalize types for viem compatibility + const normalizedTypes = { + EIP712Domain: (eip712TypedData.types as any).eip712Domain, + SafeTx: (eip712TypedData.types as any).safeTx, + }; + + return Response.json({ + eip712: eip712TypedData, + normalizedTypes, + domain: eip712TypedData.domain, + message: eip712TypedData.message, + }); + } catch (error) { + console.error("Error preparing withdraw:", error); + + if (error instanceof Error) { + return Response.json({ error: error.message }, { status: 400 }); + } + + return Response.json( + { error: "Failed to prepare withdraw" }, + { status: 500 } + ); + } +} diff --git a/wallet-earn/app/layout.tsx b/wallet-earn/app/layout.tsx index 471bad67..e6eaee89 100644 --- a/wallet-earn/app/layout.tsx +++ b/wallet-earn/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono, Inter } from "next/font/google"; -import { Analytics } from "@vercel/analytics/next" +import { Analytics } from "@vercel/analytics/next"; +import { PrivyProvider } from "@/lib/providers/privy-provider"; import "./globals.css"; const geistSans = Geist({ @@ -34,7 +35,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/wallet-earn/app/page.tsx b/wallet-earn/app/page.tsx index 12b0d450..af92166a 100644 --- a/wallet-earn/app/page.tsx +++ b/wallet-earn/app/page.tsx @@ -1,23 +1,9 @@ import Screens from "@/components/Screens"; -import { cn, generateWalletGradient, getWalletAddress } from "@/utils/utils"; export default async function Home() { - const walletAddress = getWalletAddress(); - return (
-
-
- {walletAddress.slice(0, 6)} - ●●●● - {walletAddress.slice(-4)} -
diff --git a/wallet-earn/components/EarnItem.tsx b/wallet-earn/components/EarnItem.tsx index 18b8bf70..94aa48d7 100644 --- a/wallet-earn/components/EarnItem.tsx +++ b/wallet-earn/components/EarnItem.tsx @@ -1,9 +1,14 @@ +"use client"; + import React from "react"; import { TokenData } from "./Screens"; import { EnrichedVaultData } from "./TokenScreen"; import { TrendingUp, Copy, Check } from "lucide-react"; import { cn } from "@/utils/utils"; import { Spinner } from "@geist-ui/core"; +import { usePrivy, useWallets } from "@privy-io/react-auth"; +import { useWallet } from "@/lib/hooks/use-wallet"; +import { base } from "viem/chains"; export default function EarnItem({ vaultData, @@ -129,9 +134,17 @@ function EarnForm({ isLoading: boolean; setIsClosing: (v: boolean) => void; }) { + const { signTypedData } = usePrivy(); + const { wallets } = useWallets(); + const { ownerAddress } = useWallet(); + type TabType = 'deposit' | 'withdraw'; const [activeTab, setActiveTab] = React.useState('deposit'); const [amount, setAmount] = React.useState(''); + const [error, setError] = React.useState(null); + + // Find the wallet matching the owner address to switch chains if needed + const activeWallet = wallets.find(w => w.address.toLowerCase() === ownerAddress?.toLowerCase()); const currentPosition = Number(vaultData.userPosition?.amountInUnderlyingToken || 0); const availableBalance = Number(token.amount); @@ -141,41 +154,98 @@ function EarnForm({ const isValidAmount = numericAmount > 0 && numericAmount <= maxAmount; const submitTransaction = async () => { - if (!isValidAmount) return; + if (!isValidAmount || !ownerAddress) return; setIsLoading(true); + setError(null); + try { + // Step 0: Ensure wallet is on Base network before signing + if (activeWallet) { + const currentChainId = activeWallet.chainId; + if (currentChainId !== `eip155:${base.id}`) { + try { + await activeWallet.switchChain(base.id); + } catch (switchError) { + throw new Error("Please switch to Base network to continue"); + } + } + } + const formattedAmount = numericAmount.toFixed(token.decimals); - let response: Response; - - if (activeTab === 'deposit') { - response = await fetch("/api/deposit", { - method: "POST", - body: JSON.stringify({ - vaultAddress: vaultData.address, - amount: formattedAmount, - token: token.tokenSymbol, - }), - }); - } else { - response = await fetch("/api/withdraw", { - method: "POST", - body: JSON.stringify({ - vaultAddress: vaultData.address, - amount: formattedAmount, - isAll: numericAmount === currentPosition, - token: token.tokenSymbol, - }), - }); + const isDeposit = activeTab === 'deposit'; + const prepareEndpoint = isDeposit ? '/api/deposit/prepare' : '/api/withdraw/prepare'; + const executeEndpoint = isDeposit ? '/api/deposit/execute' : '/api/withdraw/execute'; + + // Step 1: Get EIP-712 typed data from backend + // owner is the wallet that owns the earn account (external wallet or embedded wallet) + const prepareResponse = await fetch(prepareEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + vaultAddress: vaultData.address, + amount: formattedAmount, + token: token.tokenSymbol, + owner: ownerAddress, + ...(activeTab === 'withdraw' && { isAll: numericAmount === currentPosition }), + }), + }); + + if (!prepareResponse.ok) { + const errorData = await prepareResponse.json(); + throw new Error(errorData.error || "Failed to prepare transaction"); } - if (response.status === 200) { - setIsClosing(true); - setIsLoading(false); - handleRefresh(); + const { eip712, normalizedTypes, domain, message } = await prepareResponse.json(); + + // Step 2: Sign with the owner wallet + // If user connected with external wallet, sign with that wallet + // Otherwise, sign with embedded wallet (for social login users) + const signatureResult = await signTypedData( + { + domain, + types: normalizedTypes, + primaryType: "SafeTx", + message, + }, + { + // Specify which wallet address to use for signing + // This ensures external wallets (MetaMask, etc.) are used when connected + address: ownerAddress as `0x${string}`, + uiOptions: { + title: isDeposit ? "Sign Deposit" : "Sign Withdrawal", + description: `${isDeposit ? "Deposit" : "Withdraw"} ${formattedAmount} ${token.tokenSymbol}`, + buttonText: "Sign", + }, + } + ); + + const signature = typeof signatureResult === "string" + ? signatureResult + : signatureResult.signature; + + // Step 3: Execute with sponsor + const executeResponse = await fetch(executeEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + owner: ownerAddress, + eip712, + signature, + }), + }); + + if (!executeResponse.ok) { + const errorData = await executeResponse.json(); + throw new Error(errorData.error || "Failed to execute transaction"); } - } catch (error) { - console.log("error", error); + + // Success! + setIsClosing(true); + handleRefresh(); + } catch (err) { + console.error("Transaction error:", err); + setError(err instanceof Error ? err.message : "Transaction failed"); } finally { setIsLoading(false); } @@ -359,6 +429,11 @@ function EarnForm({ Insufficient {activeTab === 'deposit' ? 'balance' : 'deposited amount'}
)} + {error && ( +
+ {error} +
+ )}
diff --git a/wallet-earn/components/Screens.tsx b/wallet-earn/components/Screens.tsx index 3a7f8f2f..1149452b 100644 --- a/wallet-earn/components/Screens.tsx +++ b/wallet-earn/components/Screens.tsx @@ -10,6 +10,7 @@ import { EarnPositionsResponse, } from "@compass-labs/api-sdk/models/components"; import { AnimatePresence, motion } from "motion/react"; +import { useWallet } from "@/lib/hooks/use-wallet"; export enum Screen { Wallet, @@ -30,6 +31,8 @@ export type TokenData = TokenBalanceResponse & TokenPriceResponse; export let vaultsByToken: { [key: string]: string[] } = {}; export default function Screens() { + const { earnAccountAddress, hasEarnAccount } = useWallet(); + const [screen, setScreen] = React.useState(Screen.Wallet); const [token, setToken] = React.useState(Token.ETH); const [tokenData, setTokenData] = React.useState(); @@ -42,10 +45,10 @@ export default function Screens() { setRefreshTrigger((prev) => prev + 1); }; - const getTokenData = async () => { + const getTokenData = async (walletAddress: string) => { setTokenData(undefined); const tokenDataPromises = Object.keys(Token).map((token) => - fetch(`/api/token/${token}`).then((res) => res.json()) + fetch(`/api/token/${token}?wallet=${walletAddress}`).then((res) => res.json()) ); const tokenData: TokenData[] = await Promise.all(tokenDataPromises); setTokenData(tokenData); @@ -81,18 +84,25 @@ export default function Screens() { }); }; - const getPositionsData = async () => { + const getPositionsData = async (walletAddress: string) => { setPositionsData(undefined); - const response = await fetch(`/api/positions`); + const response = await fetch(`/api/positions?wallet=${walletAddress}`); const positions: EarnPositionsResponse = await response.json(); setPositionsData(positions); }; + // Fetch data when earn account is available React.useEffect(() => { - getTokenData(); - getVaultsListData(); - getPositionsData(); - }, [refreshTrigger]); + if (hasEarnAccount && earnAccountAddress) { + getTokenData(earnAccountAddress); + getVaultsListData(); + getPositionsData(earnAccountAddress); + } else { + // Clear data when not connected + setTokenData(undefined); + setPositionsData(undefined); + } + }, [hasEarnAccount, earnAccountAddress, refreshTrigger]); const renderScreen = () => { switch (screen) { diff --git a/wallet-earn/components/TokenScreen.tsx b/wallet-earn/components/TokenScreen.tsx index 01abd552..d6fdce9a 100644 --- a/wallet-earn/components/TokenScreen.tsx +++ b/wallet-earn/components/TokenScreen.tsx @@ -8,7 +8,7 @@ import { } from "@compass-labs/api-sdk/models/components"; import { Loading } from "@geist-ui/core"; import React from "react"; -import { cn } from "@/utils/utils"; +import { cn, calculateTokenAmount } from "@/utils/utils"; import { motion } from "motion/react"; import EarnItem from "./EarnItem"; import Skeleton from "./primitives/Skeleton"; @@ -56,17 +56,10 @@ export default function TokenScreen({ }); }, [vaultsListData, positionsData, tokenSymbol]); - // Calculate total including positions - const totalAmount = React.useMemo(() => { - if (!tokenData) return 0; - - const walletAmount = Number(tokenData.amount); - const positionsAmount = enrichedVaults.reduce((sum, vault) => { - return sum + (Number(vault.userPosition?.amountInUnderlyingToken) || 0); - }, 0); - - return walletAmount + positionsAmount; - }, [tokenData, enrichedVaults]); + const totalAmount = React.useMemo( + () => calculateTokenAmount(tokenData, enrichedVaults), + [tokenData, enrichedVaults] + ); return (
-

- {tokenData ? ( - <> - - $ - - {(totalAmount * Number(tokenData.price)).toFixed(2)} - - ) : ( - - )} -

-
+
{tokenData ? ( totalAmount.toFixed(3) ) : ( - + )}{" "} {tokenSymbol}
+
Available balance
diff --git a/wallet-earn/components/WalletScreen.tsx b/wallet-earn/components/WalletScreen.tsx index 372b317e..88c631f2 100644 --- a/wallet-earn/components/WalletScreen.tsx +++ b/wallet-earn/components/WalletScreen.tsx @@ -1,7 +1,14 @@ +"use client"; + import React from "react"; import { Screen, Token, TokenData, vaultsByToken } from "./Screens"; import { VaultsListResponse } from "@compass-labs/api-sdk/models/components"; import Skeleton from "./primitives/Skeleton"; +import { useWallet } from "@/lib/hooks/use-wallet"; +import { useFundWallet } from "@privy-io/react-auth"; +import { base } from "viem/chains"; +import { LogOut, Loader2 } from "lucide-react"; +import { cn } from "@/utils/utils"; export default function WalletScreen({ setScreen, @@ -14,14 +21,131 @@ export default function WalletScreen({ tokenData?: TokenData[]; vaultsListData?: VaultsListResponse; }) { - // Calculate total balance from token data only (wallet balances) - const totalBalance = tokenData?.reduce((sum, token) => { - return sum + (Number(token.amount) * Number(token.price)); - }, 0) || 0; + const { + isConnected, + isInitializing, + login, + logout, + hasEarnAccount, + isCreatingEarnAccount, + createEarnAccount, + earnAccountAddress, + } = useWallet(); + + const { fundWallet } = useFundWallet(); + + const handleFundAccount = async () => { + if (!earnAccountAddress) return; + await fundWallet({ + address: earnAccountAddress, + options: { + chain: base, + asset: "USDC", + amount: "10", + }, + }); + }; + + // Calculate total from wallet balances only + const totalBalance = + tokenData?.reduce((sum, token) => { + return sum + Number(token.amount) * Number(token.price); + }, 0) || 0; + + // Not connected state - show login button + if (!isConnected && !isInitializing) { + return ( +
+
+

Welcome to Compass Earn

+

+ Connect your wallet to start earning yield on your crypto assets +

+
+ +
+ ); + } + + // Loading state + if (isInitializing) { + return ( +
+ +

Loading...

+
+ ); + } + + // Connected but no earn account - show create account button + if (!hasEarnAccount) { + return ( +
+
+

Create Earn Account

+

+ Create your Compass Earn account to start depositing and earning + yield +

+
+ + +
+ ); + } + + // Connected with earn account - show main wallet view return (
-
+ {/* Header with fund and logout */} +
+ + +
+ + {/* Balance display */} +
{tokenData ? ( <> @@ -36,6 +160,8 @@ export default function WalletScreen({
Total value
+ + {/* Token list */}
    {Object.keys(Token).map((tokenSymbol) => ( Promise
    ; + }; + + // Auth state + isConnected: boolean; + isInitializing: boolean; + + // Actions + login: () => void; + logout: () => Promise; +} + +const WalletContext = createContext(null); + +// Create public client for reading blockchain data +const publicClient = createPublicClient({ + chain: base, + transport: http(process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org"), +}); + +/** + * Check if an earn account is deployed at the given address + */ +async function isAccountDeployed(address: Address): Promise { + try { + const bytecode = await publicClient.getCode({ address }); + return bytecode !== undefined && bytecode !== "0x"; + } catch { + return false; + } +} + +export function WalletProvider({ children }: { children: React.ReactNode }) { + const { authenticated, user, login, logout } = usePrivy(); + const { sendTransaction } = useSendTransaction(); + + // Embedded wallet state (created by Privy for social logins) + const [embeddedWalletAddress, setEmbeddedWalletAddress] = + useState
    (null); + + // Connected external wallet state (MetaMask, Coinbase, etc.) + const [connectedWalletAddress, setConnectedWalletAddress] = + useState
    (null); + + // Earn account state + const [earnAccountAddress, setEarnAccountAddress] = useState
    ( + null + ); + const [isEarnAccountCreated, setIsEarnAccountCreated] = useState(false); + const [isCreatingAccount, setIsCreatingAccount] = useState(false); + + // Loading state + const [isInitializing, setIsInitializing] = useState(true); + + /** + * Extract wallets from Privy user object + * Priority: External connected wallet > Embedded wallet + */ + useEffect(() => { + if (!authenticated || !user) { + setEmbeddedWalletAddress(null); + setConnectedWalletAddress(null); + setEarnAccountAddress(null); + setIsEarnAccountCreated(false); + setIsInitializing(false); + return; + } + + // Find embedded wallet (created by Privy for social logins) + const embeddedAccount = user.linkedAccounts?.find( + (account) => + account.type === "wallet" && + account.walletClientType === "privy" && + account.chainType === "ethereum" + ); + + // Find external connected wallet (MetaMask, Coinbase, etc.) + // This is any wallet that is NOT the Privy embedded wallet + const externalWallet = user.linkedAccounts?.find( + (account) => + account.type === "wallet" && + account.walletClientType !== "privy" && + account.chainType === "ethereum" + ); + + if ( + embeddedAccount && + embeddedAccount.type === "wallet" && + embeddedAccount.walletClientType === "privy" + ) { + setEmbeddedWalletAddress(embeddedAccount.address as Address); + } else { + setEmbeddedWalletAddress(null); + } + + if ( + externalWallet && + externalWallet.type === "wallet" + ) { + setConnectedWalletAddress(externalWallet.address as Address); + } else { + setConnectedWalletAddress(null); + } + + setIsInitializing(false); + }, [authenticated, user]); + + // Owner address: prioritize external wallet over embedded wallet + const ownerAddress = connectedWalletAddress || embeddedWalletAddress; + + /** + * Check if earn account exists when wallet is connected + * Uses ownerAddress (external wallet if connected, otherwise embedded wallet) + */ + const { data: earnAccountData, refetch: refetchEarnAccount } = useQuery({ + queryKey: ["earn-account", ownerAddress], + queryFn: async () => { + if (!ownerAddress) return null; + + const response = await fetch( + `/api/earn-account/check?owner=${ownerAddress}` + ); + if (!response.ok) return null; + + const data = await response.json(); + return data as { earnAccountAddress: string; isDeployed: boolean } | null; + }, + enabled: !!ownerAddress && authenticated, + staleTime: 60 * 1000, // 1 minute + }); + + // Update earn account state from query + useEffect(() => { + if (earnAccountData) { + setEarnAccountAddress(earnAccountData.earnAccountAddress as Address); + setIsEarnAccountCreated(earnAccountData.isDeployed); + } + }, [earnAccountData]); + + /** + * Create a new Earn account via Compass API + * Uses ownerAddress as the owner of the earn account + */ + const createEarnAccount = useCallback(async (): Promise
    => { + if (!ownerAddress) { + throw new Error("No wallet connected"); + } + + if (isEarnAccountCreated) { + throw new Error("Earn account already exists"); + } + + setIsCreatingAccount(true); + + try { + // Step 1: Get unsigned transaction from API + const response = await fetch("/api/earn-account/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + owner: ownerAddress, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to create earn account"); + } + + const { transaction, earnAccountAddress: predictedAddress } = + await response.json(); + + // Step 2: Send transaction using Privy + const txResult = await sendTransaction( + { + to: transaction.to as Address, + data: transaction.data as Hex, + chainId: base.id, + value: BigInt(transaction.value || "0"), + }, + { + sponsor: true, + uiOptions: { + description: + "Sign this transaction to create your Compass Earn account.", + buttonText: "Create Account", + } + } + ); + + const txHash = + typeof txResult === "string" ? txResult : txResult.hash; + + // Step 3: Wait for confirmation + await publicClient.waitForTransactionReceipt({ + hash: txHash as Hex, + confirmations: 1, + }); + + // Step 4: Update state + setEarnAccountAddress(predictedAddress as Address); + setIsEarnAccountCreated(true); + + // Refetch to confirm + await refetchEarnAccount(); + + return predictedAddress as Address; + } catch (error) { + console.error("Failed to create earn account:", error); + throw error; + } finally { + setIsCreatingAccount(false); + } + }, [ + ownerAddress, + isEarnAccountCreated, + sendTransaction, + refetchEarnAccount, + ]); + + const handleLogout = useCallback(async () => { + setEmbeddedWalletAddress(null); + setConnectedWalletAddress(null); + setEarnAccountAddress(null); + setIsEarnAccountCreated(false); + await logout(); + }, [logout]); + + // Connected if authenticated and has any wallet (external or embedded) + const isConnected = authenticated && !!ownerAddress; + + const value: WalletContextValue = { + ownerAddress, + embeddedWallet: { + address: embeddedWalletAddress, + }, + connectedWallet: { + address: connectedWalletAddress, + }, + earnAccount: { + address: earnAccountAddress, + isCreated: isEarnAccountCreated, + isCreating: isCreatingAccount, + createAccount: createEarnAccount, + }, + isConnected, + isInitializing, + login, + logout: handleLogout, + }; + + return ( + {children} + ); +} + +/** + * Default context value for when provider is not available (SSR, build, or Privy not configured) + */ +const defaultContextValue: WalletContextValue = { + ownerAddress: null, + embeddedWallet: { + address: null, + }, + connectedWallet: { + address: null, + }, + earnAccount: { + address: null, + isCreated: false, + isCreating: false, + createAccount: async () => { + throw new Error("Wallet provider not available"); + }, + }, + isConnected: false, + isInitializing: true, // Show loading state by default + login: () => { + console.warn("Wallet provider not available"); + }, + logout: async () => { + console.warn("Wallet provider not available"); + }, +}; + +/** + * Hook for accessing wallet context + */ +export function useWalletContext() { + const context = useContext(WalletContext); + + // Return safe defaults when not within provider (SSR, build, Privy not configured) + if (!context) { + return defaultContextValue; + } + + return context; +} diff --git a/wallet-earn/lib/hooks/use-wallet.ts b/wallet-earn/lib/hooks/use-wallet.ts new file mode 100644 index 00000000..e855cfc3 --- /dev/null +++ b/wallet-earn/lib/hooks/use-wallet.ts @@ -0,0 +1,47 @@ +/** + * Convenience hook for accessing wallet functionality + */ + +import { useWalletContext } from "@/lib/contexts/wallet-context"; + +export function useWallet() { + const { + ownerAddress, + embeddedWallet, + connectedWallet, + earnAccount, + isConnected, + isInitializing, + login, + logout, + } = useWalletContext(); + + return { + // Owner address - the wallet that owns the earn account + // This is the external wallet if connected, otherwise the embedded wallet + ownerAddress, + + // Legacy alias for backwards compatibility + address: ownerAddress, + + // Embedded wallet (created by Privy for social logins) + embeddedWalletAddress: embeddedWallet.address, + + // Connected external wallet (MetaMask, Coinbase, etc.) + connectedWalletAddress: connectedWallet.address, + + // Earn account (proxy wallet that holds funds) + earnAccountAddress: earnAccount.address, + hasEarnAccount: earnAccount.isCreated, + isCreatingEarnAccount: earnAccount.isCreating, + createEarnAccount: earnAccount.createAccount, + + // Auth state + isConnected, + isInitializing, + + // Actions + login, + logout, + }; +} diff --git a/wallet-earn/lib/providers/privy-provider.tsx b/wallet-earn/lib/providers/privy-provider.tsx new file mode 100644 index 00000000..686bf6a6 --- /dev/null +++ b/wallet-earn/lib/providers/privy-provider.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { PrivyProvider as PrivyProviderBase } from "@privy-io/react-auth"; +import { base } from "viem/chains"; +import { QueryProvider } from "./query-provider"; +import { WalletProvider } from "@/lib/contexts/wallet-context"; + +const PRIVY_APP_ID = process.env.NEXT_PUBLIC_PRIVY_APP_ID; + +export function PrivyProvider({ children }: { children: React.ReactNode }) { + // During build or if no app ID is set, render without Privy + // WalletProvider will use safe defaults since Privy hooks won't work + if (!PRIVY_APP_ID) { + console.warn("NEXT_PUBLIC_PRIVY_APP_ID not set - wallet features disabled"); + return ( + + {children} + + ); + } + + return ( + + + + {children} + + + + ); +} diff --git a/wallet-earn/lib/providers/query-provider.tsx b/wallet-earn/lib/providers/query-provider.tsx new file mode 100644 index 00000000..3d52cd86 --- /dev/null +++ b/wallet-earn/lib/providers/query-provider.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState } from "react"; + +export function QueryProvider({ children }: { children: React.ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30 * 1000, // 30 seconds + gcTime: 2 * 60 * 1000, // 2 minutes + }, + }, + }) + ); + + return ( + + {children} + + ); +} diff --git a/wallet-earn/package.json b/wallet-earn/package.json index 461db8a3..214afd1a 100644 --- a/wallet-earn/package.json +++ b/wallet-earn/package.json @@ -9,9 +9,13 @@ "lint": "next lint" }, "dependencies": { - "@compass-labs/api-sdk": "1.3.5", + "@compass-labs/api-sdk": "2.1.6", "@geist-ui/core": "^2.3.8", + "@privy-io/react-auth": "^3.8.0", "@radix-ui/react-slider": "^1.3.6", + "@solana-program/system": "^0.10.0", + "@solana/web3.js": "^1.98.4", + "@tanstack/react-query": "^5.90.10", "@vercel/analytics": "^1.5.0", "clsx": "^2.1.1", "lucide-react": "^0.540.0", @@ -29,5 +33,8 @@ "@types/react-dom": "^19", "tailwindcss": "^4", "typescript": "^5" + }, + "overrides": { + "ox": "0.9.6" } } diff --git a/wallet-earn/utils/utils.ts b/wallet-earn/utils/utils.ts index 1088ddeb..90d36aa3 100644 --- a/wallet-earn/utils/utils.ts +++ b/wallet-earn/utils/utils.ts @@ -67,3 +67,17 @@ export const addTotalBalance = ( const tokenTotal = addTokenTotal(tokenSingle, vaultData); return sum + tokenTotal * Number(token.price); }, 0); + +/** + * Calculate total token amount (wallet + positions) for a specific token + */ +export function calculateTokenAmount( + tokenData: TokenData | undefined, + enrichedVaults: EnrichedVaultData[] +): number { + if (!tokenData) return 0; + + const walletAmount = Number(tokenData.amount); + + return walletAmount; +}