diff --git a/.env.example b/.env.example index db42dfa2..4ebd7218 100644 --- a/.env.example +++ b/.env.example @@ -114,6 +114,25 @@ NEXT_PUBLIC_BLOCKFEST_END_DATE=2025-10-11T23:59:00+01:00 CASHBACK_WALLET_ADDRESS= CASHBACK_WALLET_PRIVATE_KEY= +# ============================================================================= +# Starknet Configuration +# ============================================================================= + +# Starknet Network RPC +NEXT_PUBLIC_STARKNET_RPC_URL=https://starknet-mainnet.public.blastapi.io + +# Ready Account Class Hash (Argent Ready Account) +NEXT_PUBLIC_STARKNET_READY_CLASSHASH=0x073414441639dcd11d1846f287650a00c60c416b9d3ba45d31c651672125b2c2 +STARKNET_READY_CLASSHASH=0x073414441639dcd11d1846f287650a00c60c416b9d3ba45d31c651672125b2c2 + +# Paymaster Configuration (AVNU) +STARKNET_PAYMASTER_URL=https://mainnet.paymaster.avnu.fi +STARKNET_PAYMASTER_MODE=sponsored +STARKNET_PAYMASTER_API_KEY= + +# Gas token address (required if PAYMASTER_MODE=default) +STARKNET_GAS_TOKEN_ADDRESS= + # ============================================================================= # Content Management (Sanity) # ============================================================================= diff --git a/app/api/starknet/create-order/route.ts b/app/api/starknet/create-order/route.ts new file mode 100644 index 00000000..39729d2b --- /dev/null +++ b/app/api/starknet/create-order/route.ts @@ -0,0 +1,288 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifyJWT } from "@/app/lib/jwt"; +import { DEFAULT_PRIVY_CONFIG } from "@/app/lib/config"; +import { + buildReadyAccount, + deployReadyAccount, + getRpcProvider, + getStarknetWallet, + setupPaymaster, +} from "@/app/lib/starknet"; +import { cairo, CallData, byteArray } from "starknet"; + +export async function POST(request: NextRequest) { + let isDeployed = false; + try { + // Extract and verify JWT token + const authHeader = request.headers.get("authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return NextResponse.json( + { error: "Missing or invalid authorization header" }, + { status: 401 }, + ); + } + + const token = authHeader.substring(7); + const { payload } = await verifyJWT(token, DEFAULT_PRIVY_CONFIG); + const authUserId = payload.sub || payload.userId; + + if (!authUserId) { + return NextResponse.json( + { error: "Invalid token: missing user ID" }, + { status: 401 }, + ); + } + + // Get request body + const body = await request.json(); + const { + walletId, + publicKey, + classHash: clientClassHash, + tokenAddress, + gatewayAddress, + amount, + rate, + senderFeeRecipient, + senderFee, + refundAddress, + messageHash, + origin: clientOrigin, + address: WalletAddress, + } = body; + + const provider = getRpcProvider(); + try { + await provider.getClassHashAt(WalletAddress); + isDeployed = true; + } catch { + isDeployed = false; + } + + // Validate required fields + if (!walletId || !publicKey || !tokenAddress || !gatewayAddress) { + return NextResponse.json( + { + error: "Missing required fields", + missing: { + walletId: !walletId, + publicKey: !publicKey, + tokenAddress: !tokenAddress, + gatewayAddress: !gatewayAddress, + }, + }, + { status: 400 }, + ); + } + + if ( + amount === undefined || + rate === undefined || + !senderFeeRecipient || + senderFee === undefined || + !refundAddress || + !messageHash + ) { + return NextResponse.json( + { + error: "Missing transaction parameters", + missing: { + amount: amount === undefined, + rate: rate === undefined, + senderFeeRecipient: !senderFeeRecipient, + senderFee: senderFee === undefined, + refundAddress: !refundAddress, + messageHash: !messageHash, + }, + }, + { status: 400 }, + ); + } + + // Use class hash from client or fallback to server env + const classHash = clientClassHash || process.env.STARKNET_READY_CLASSHASH; + if (!classHash) { + return NextResponse.json( + { error: "STARKNET_READY_CLASSHASH not configured" }, + { status: 500 }, + ); + } + + // Use origin from client or header + const origin = clientOrigin || request.headers.get("origin") || undefined; + + const { publicKey: walletPublicKey } = await getStarknetWallet(walletId); + + // Setup paymaster if configured + const usePaymaster = !!( + process.env.STARKNET_PAYMASTER_URL && process.env.STARKNET_PAYMASTER_MODE + ); + + if (!usePaymaster) { + return NextResponse.json( + { error: "Paymaster not configured" }, + { status: 500 }, + ); + } + + let config; + try { + config = await setupPaymaster(); + } catch (e: any) { + return NextResponse.json( + { error: e?.message || "Failed to initialize paymaster" }, + { status: 500 }, + ); + } + + const { paymasterRpc, isSponsored, gasToken } = config; + + // Build account with paymaster support + const { account, address } = await buildReadyAccount({ + walletId, + publicKey: walletPublicKey, + classHash, + userJwt: token, + userId: authUserId, + origin, + paymasterRpc, + }); + + // Convert amounts to u256 (following the working script pattern) + const amountU256 = cairo.uint256(BigInt(amount)); + const senderFeeU256 = cairo.uint256(BigInt(senderFee)); + + // Encode message hash as Cairo ByteArray + const messageHashByteArray = byteArray.byteArrayFromString(messageHash); + + // Calculate total amount (amount + senderFee) + const totalAmount = BigInt(amount) + BigInt(senderFee); + const totalAmountU256 = cairo.uint256(totalAmount); + + // Prepare calls using manual call structure (more reliable than populate) + const calls = [ + // 1. Approve gateway to spend tokens + { + contractAddress: tokenAddress, + entrypoint: "approve", + calldata: CallData.compile({ + spender: gatewayAddress, + amount: totalAmountU256, + }), + }, + // 2. Create order + { + contractAddress: gatewayAddress, + entrypoint: "create_order", + calldata: CallData.compile({ + token: tokenAddress, + amount: amountU256, + rate: rate, + sender_fee_recipient: senderFeeRecipient, + sender_fee: senderFeeU256, + refund_address: refundAddress, + message_hash: messageHashByteArray, // Use encoded ByteArray + }), + }, + ]; + + // Prepare paymaster details + const paymasterDetails: any = isSponsored + ? { feeMode: { mode: "sponsored" as const } } + : { feeMode: { mode: "default" as const, gasToken } }; + + // Estimate fees if not sponsored + let maxFee: any = undefined; + if (!isSponsored) { + try { + const est = await account.estimatePaymasterTransactionFee( + calls, + paymasterDetails, + ); + const withMargin15 = (v: any) => { + const bi = BigInt(v.toString()); + return (bi * BigInt(3) + BigInt(1)) / BigInt(2); // ceil(1.5x) + }; + maxFee = withMargin15(est.suggested_max_fee_in_gas_token); + } catch (error: any) { + console.error("[API] Fee estimation failed:", error.message); + return NextResponse.json( + { error: `Fee estimation failed: ${error.message}` }, + { status: 500 }, + ); + } + } + + // Execute transaction with paymaster + let result; + try { + if (!isDeployed) { + result = await deployReadyAccount({ + walletId, + publicKey: walletPublicKey, + classHash, + userJwt: token, + userId: authUserId, + origin, + calls, + }); + } else { + result = await account.executePaymasterTransaction( + calls, + paymasterDetails, + maxFee, + ); + } + } catch (error: any) { + console.error("[API] Error executing transaction:", error); + return NextResponse.json( + { error: error.message || "Failed to execute transaction" }, + { status: 500 }, + ); + } + + let orderId; + + // Wait for transaction confirmation + const wait = true; + if (wait) { + try { + const txReceipt = await account.waitForTransaction( + result.transaction_hash, + ); + if (txReceipt.isSuccess()) { + const rawEvents = txReceipt.value.events; + rawEvents.forEach((event) => { + if ( + Object.values(event.keys).includes( + "0x3427759bfd3b941f14e687e129519da3c9b0046c5b9aaa290bb1dede63753b3", + ) + ) { + orderId = event.data[2]; + } + }); + } + } catch (error) { + console.log( + "[API] Warning: Could not confirm transaction, but it may still succeed", + ); + } + } + + return NextResponse.json({ + success: true, + walletId, + address, + transactionHash: result.transaction_hash, + mode: isSponsored ? "sponsored" : "default", + messageHash, + orderId, + }); + } catch (error: any) { + console.error("[API] Error executing transaction:", error); + return NextResponse.json( + { error: error.message || "Failed to execute transaction" }, + { status: 500 }, + ); + } +} diff --git a/app/api/starknet/create-wallet/route.ts b/app/api/starknet/create-wallet/route.ts new file mode 100644 index 00000000..7e706ecf --- /dev/null +++ b/app/api/starknet/create-wallet/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPrivyClient } from "@/app/lib/privy"; +import { verifyJWT } from "@/app/lib/jwt"; +import { DEFAULT_PRIVY_CONFIG } from "@/app/lib/config"; + +/** + * POST /api/starknet/create-wallet + * Creates a new Starknet wallet for the user via Privy + */ +export async function POST(request: NextRequest) { + try { + // Extract and verify JWT token + const authHeader = request.headers.get("authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return NextResponse.json( + { error: "Missing or invalid authorization header" }, + { status: 401 }, + ); + } + + const token = authHeader.substring(7); + const { payload } = await verifyJWT(token, DEFAULT_PRIVY_CONFIG); + const authUserId = payload.sub || payload.userId; + + if (!authUserId) { + return NextResponse.json( + { error: "Invalid token: missing user ID" }, + { status: 401 }, + ); + } + + // Get request body + const body = await request.json(); + + const userId = authUserId; + + if (!userId) { + return NextResponse.json( + { error: "No user ID available" }, + { status: 400 }, + ); + } + + // First, check if user already has a Starknet wallet + const privy = getPrivyClient(); + + let existingStarknetWallet = null; + try { + const user = await privy.getUser(userId); + const linkedAccounts = user.linkedAccounts || []; + existingStarknetWallet = linkedAccounts.find( + (account: any) => + account.type === "wallet" && + (account.chainType === "starknet" || + account.chain_type === "starknet"), + ); + + if (existingStarknetWallet) { + const wallet = existingStarknetWallet as any; + return NextResponse.json({ + success: true, + wallet: { + id: wallet.id, + address: wallet.address, + publicKey: wallet.public_key || wallet.publicKey, + chainType: wallet.chainType || wallet.chain_type, + }, + }); + } + } catch (error) { + console.error("Error checking for existing Starknet wallet:", error); + // Re-throw the error to prevent duplicate wallet creation + throw error; + } + + // Create Starknet wallet via Privy + const walletPayload = { + chainType: "starknet", + owner: { userId }, + } as any; + + const wallet = await privy.walletApi.createWallet(walletPayload); + + return NextResponse.json({ + success: true, + wallet: { + id: wallet.id, + address: wallet.address, + publicKey: (wallet as any).public_key || (wallet as any).publicKey, + chainType: (wallet as any).chainType || (wallet as any).chain_type, + }, + }); + } catch (error: any) { + console.error("Error creating Starknet wallet:", error); + return NextResponse.json( + { + error: error.message || "Failed to create Starknet wallet", + }, + { status: 500 }, + ); + } +} diff --git a/app/api/starknet/get-public-key/route.ts b/app/api/starknet/get-public-key/route.ts new file mode 100644 index 00000000..a4b6b986 --- /dev/null +++ b/app/api/starknet/get-public-key/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifyJWT } from "@/app/lib/jwt"; +import { DEFAULT_PRIVY_CONFIG } from "@/app/lib/config"; +import { getPrivyClient } from "@/app/lib/privy"; + +/** + * POST /api/starknet/get-public-key + * Fetches the public key for a Starknet wallet from Privy + */ +export async function POST(request: NextRequest) { + try { + // Extract and verify JWT token + const authHeader = request.headers.get("authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return NextResponse.json( + { error: "Missing or invalid authorization header" }, + { status: 401 } + ); + } + + const token = authHeader.substring(7); + const { payload } = await verifyJWT(token, DEFAULT_PRIVY_CONFIG); + const authUserId = payload.sub || payload.userId; + + if (!authUserId) { + return NextResponse.json( + { error: "Invalid token: missing user ID" }, + { status: 401 } + ); + } + + // Get request body + const body = await request.json(); + const { walletId } = body; + + if (!walletId) { + return NextResponse.json( + { error: "walletId is required" }, + { status: 400 } + ); + } + + const privy = getPrivyClient(); + const wallet: any = await privy.walletApi.getWallet({ id: walletId }); + + // Verify that the wallet belongs to the authenticated user + const user = await privy.getUser(authUserId); + const linkedAccounts = user.linkedAccounts || []; + const ownsWallet = linkedAccounts.find( + (account: any) => account.id === walletId && account.type === "wallet" + ); + + if (!ownsWallet) { + return NextResponse.json( + { error: "Unauthorized: wallet does not belong to this user" }, + { status: 403 } + ); + } + + const publicKey = wallet.public_key || wallet.publicKey; + + if (!publicKey) { + return NextResponse.json({ + error: "Public key not available from Privy. " + + "This may require enabling Starknet support in your Privy dashboard or " + + "upgrading to a Privy tier that supports Starknet public key access.", + }, { status: 404 }); + } + + return NextResponse.json({ + success: true, + publicKey: publicKey, + }); + + } catch (error: any) { + return NextResponse.json( + { + error: error.message || "Failed to fetch public key", + }, + { status: 500 } + ); + } +} diff --git a/app/api/starknet/transfer/route.ts b/app/api/starknet/transfer/route.ts new file mode 100644 index 00000000..6788549e --- /dev/null +++ b/app/api/starknet/transfer/route.ts @@ -0,0 +1,231 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifyJWT } from "@/app/lib/jwt"; +import { DEFAULT_PRIVY_CONFIG } from "@/app/lib/config"; +import { + buildReadyAccount, + deployReadyAccount, + getRpcProvider, + getStarknetWallet, + setupPaymaster, +} from "@/app/lib/starknet"; +import { cairo, CallData } from "starknet"; + +export async function POST(request: NextRequest) { + let isDeployed = false; + try { + // Extract and verify JWT token + const authHeader = request.headers.get("authorization"); + if (!authHeader?.startsWith("Bearer ")) { + return NextResponse.json( + { error: "Missing or invalid authorization header" }, + { status: 401 }, + ); + } + + const token = authHeader.substring(7); + const { payload } = await verifyJWT(token, DEFAULT_PRIVY_CONFIG); + const authUserId = payload.sub || payload.userId; + + if (!authUserId) { + return NextResponse.json( + { error: "Invalid token: missing user ID" }, + { status: 401 }, + ); + } + + // Get request body + const body = await request.json(); + const { + walletId, + publicKey, + classHash: clientClassHash, + tokenAddress, + amount, + recipientAddress, + origin: clientOrigin, + address: WalletAddress, + } = body; + + const provider = getRpcProvider(); + try { + await provider.getClassHashAt(WalletAddress); + isDeployed = true; + } catch { + isDeployed = false; + } + + // Validate required fields + if (!walletId || !publicKey || !tokenAddress || !recipientAddress) { + return NextResponse.json( + { + error: "Missing required fields", + missing: { + walletId: !walletId, + publicKey: !publicKey, + tokenAddress: !tokenAddress, + recipientAddress: !recipientAddress, + }, + }, + { status: 400 }, + ); + } + + if (amount === undefined || amount === null) { + return NextResponse.json( + { error: "Missing amount parameter" }, + { status: 400 }, + ); + } + + // Use class hash from client or fallback to server env + const classHash = clientClassHash || process.env.STARKNET_READY_CLASSHASH; + if (!classHash) { + return NextResponse.json( + { error: "STARKNET_READY_CLASSHASH not configured" }, + { status: 500 }, + ); + } + + // Use origin from client or header + const origin = clientOrigin || request.headers.get("origin") || undefined; + + // Get wallet public key from Privy + const { publicKey: walletPublicKey } = await getStarknetWallet(walletId); + // Setup paymaster if configured + const usePaymaster = !!( + process.env.STARKNET_PAYMASTER_URL && process.env.STARKNET_PAYMASTER_MODE + ); + + if (!usePaymaster) { + return NextResponse.json( + { error: "Paymaster not configured" }, + { status: 500 }, + ); + } + + let config; + try { + config = await setupPaymaster(); + } catch (e: any) { + return NextResponse.json( + { error: e?.message || "Failed to initialize paymaster" }, + { status: 500 }, + ); + } + const { paymasterRpc, isSponsored, gasToken } = config; + + // Build account without paymaster (user pays gas for transfers) + const { account, address } = await buildReadyAccount({ + walletId, + publicKey: walletPublicKey, + classHash, + userJwt: token, + userId: authUserId, + origin, + paymasterRpc, + }); + + // Convert amount to u256 format + const amountU256 = cairo.uint256(BigInt(amount)); + + // Prepare transfer call + const calls = [ + { + contractAddress: tokenAddress, + entrypoint: "transfer", + calldata: CallData.compile({ + recipient: recipientAddress, + amount: amountU256, + }), + }, + ]; + + // Prepare paymaster details + const paymasterDetails: any = isSponsored + ? { feeMode: { mode: "sponsored" as const } } + : { feeMode: { mode: "default" as const, gasToken } }; + + // Estimate fees if not sponsored + let maxFee: any = undefined; + if (!isSponsored) { + try { + const est = await account.estimatePaymasterTransactionFee( + calls, + paymasterDetails, + ); + const withMargin15 = (v: any) => { + const bi = BigInt(v.toString()); + return (bi * BigInt(3) + BigInt(1)) / BigInt(2); // ceil(1.5x) + }; + maxFee = withMargin15(est.suggested_max_fee_in_gas_token); + } catch (error: any) { + console.error("[API] Fee estimation failed:", error.message); + return NextResponse.json( + { error: `Fee estimation failed: ${error.message}` }, + { status: 500 }, + ); + } + } + + // Execute transfer transaction + let result; + try { + if (!isDeployed) { + result = await deployReadyAccount({ + walletId, + publicKey: walletPublicKey, + classHash, + userJwt: token, + userId: authUserId, + origin, + calls, + }); + } else { + result = await account.executePaymasterTransaction( + calls, + paymasterDetails, + maxFee, + ); + } + } catch (error: any) { + console.error("[API] Error executing transaction:", error); + return NextResponse.json( + { error: error.message || "Failed to execute transaction" }, + { status: 500 }, + ); + } + + // Wait for transaction confirmation + try { + const txReceipt = await account.waitForTransaction( + result.transaction_hash, + ); + + if (!txReceipt.isSuccess()) { + return NextResponse.json( + { error: "Transaction reverted on-chain" }, + { status: 500 }, + ); + } + } catch (error) { + console.log( + "[API] Warning: Could not confirm transaction, but it may still succeed", + ); + } + + return NextResponse.json({ + success: true, + transactionHash: result.transaction_hash, + walletId, + address, + amount: amount.toString(), + recipient: recipientAddress, + }); + } catch (error: any) { + console.error("[API] Error in Starknet transfer:", error); + return NextResponse.json( + { error: error.message || "Failed to process transfer" }, + { status: 500 }, + ); + } +} diff --git a/app/api/track-logout/route.ts b/app/api/track-logout/route.ts index 54261bad..dab937d7 100644 --- a/app/api/track-logout/route.ts +++ b/app/api/track-logout/route.ts @@ -31,7 +31,7 @@ export async function POST(request: NextRequest) { }; const wallet = typeof walletAddress === "string" ? walletAddress.trim() : ""; - if (!/^0x[a-fA-F0-9]{40}$/.test(wallet)) { + if (!/^0x[a-fA-F0-9]{40}$|^0x[a-fA-F0-9]{64}$/.test(wallet)) { return NextResponse.json( { success: false, diff --git a/app/components/MainPageContent.tsx b/app/components/MainPageContent.tsx index 6949cdc4..41788279 100644 --- a/app/components/MainPageContent.tsx +++ b/app/components/MainPageContent.tsx @@ -19,7 +19,11 @@ import BlockFestCashbackModal from "./blockfest/BlockFestCashbackModal"; import { useBlockFestClaim } from "../context/BlockFestClaimContext"; import { BlockFestClaimGate } from "./blockfest/BlockFestClaimGate"; import { useBlockFestReferral } from "../hooks/useBlockFestReferral"; -import { fetchRate, fetchSupportedInstitutions, migrateLocalStorageRecipients } from "../api/aggregator"; +import { + fetchRate, + fetchSupportedInstitutions, + migrateLocalStorageRecipients, +} from "../api/aggregator"; import { normalizeNetworkForRateFetch } from "../utils"; import { STEPS, @@ -37,6 +41,7 @@ import { HomePage } from "./HomePage"; import { useNetwork } from "../context/NetworksContext"; import { useBlockFestModal } from "../context/BlockFestModalContext"; import { useInjectedWallet } from "../context"; +import { useWalletAddress } from "../hooks/useWalletAddress"; const PageLayout = ({ authenticated, @@ -56,19 +61,8 @@ const PageLayout = ({ const { claimed, resetClaim } = useBlockFestClaim(); const { user } = usePrivy(); const { isOpen, openModal, closeModal } = useBlockFestModal(); - const { isInjectedWallet, injectedAddress } = useInjectedWallet(); - - // Clean up claim state when user logs out - useEffect(() => { - if (!authenticated && !isInjectedWallet) { - resetClaim(); - } - }, [authenticated, isInjectedWallet, resetClaim]); - - const walletAddress = isInjectedWallet - ? injectedAddress - : user?.linkedAccounts.find((account) => account.type === "smart_wallet") - ?.address; + const { isInjectedWallet } = useInjectedWallet(); + const walletAddress = useWalletAddress(); return ( <> @@ -149,30 +143,30 @@ export function MainPageContent() { // State props for child components const stateProps: StateProps = { - formValues, - setFormValues, - - rate, - setRate, - isFetchingRate, - setIsFetchingRate, - rateError, - setRateError, - - institutions, - setInstitutions, - isFetchingInstitutions, - setIsFetchingInstitutions, - - selectedRecipient, - setSelectedRecipient, - - orderId, - setOrderId, - setCreatedAt, - setTransactionStatus, - } - + formValues, + setFormValues, + + rate, + setRate, + isFetchingRate, + setIsFetchingRate, + rateError, + setRateError, + + institutions, + setInstitutions, + isFetchingInstitutions, + setIsFetchingInstitutions, + + selectedRecipient, + setSelectedRecipient, + + orderId, + setOrderId, + setCreatedAt, + setTransactionStatus, + }; + useEffect(function setPageLoadingState() { setOrderId(""); setIsPageLoading(false); diff --git a/app/components/MobileDropdown.tsx b/app/components/MobileDropdown.tsx index 4a958b08..7ad8ed38 100644 --- a/app/components/MobileDropdown.tsx +++ b/app/components/MobileDropdown.tsx @@ -4,7 +4,7 @@ import { AnimatePresence, motion } from "framer-motion"; import { useState, useEffect } from "react"; import { usePrivy, useMfaEnrollment } from "@privy-io/react-auth"; import { useNetwork } from "../context/NetworksContext"; -import { useBalance, useTokens } from "../context"; +import { useBalance, useTokens, useStarknet } from "../context"; import { handleNetworkSwitch, detectWalletProvider } from "../utils"; import { useLogout } from "@privy-io/react-auth"; import { toast } from "sonner"; @@ -14,6 +14,7 @@ import { useFundWalletHandler } from "../hooks/useFundWalletHandler"; import { useInjectedWallet } from "../context"; import { useWalletDisconnect } from "../hooks/useWalletDisconnect"; import { useActualTheme } from "../hooks/useActualTheme"; +import { useWalletAddress } from "../hooks/useWalletAddress"; import { useSmartWallets } from "@privy-io/react-auth/smart-wallets"; import { useTransactions } from "../context/TransactionsContext"; import { networks } from "../mocks"; @@ -41,22 +42,23 @@ export const MobileDropdown = ({ const { user, linkEmail, updateEmail } = usePrivy(); const { allBalances, isLoading, refreshBalance } = useBalance(); const { allTokens } = useTokens(); + const { ensureWalletExists } = useStarknet(); const { logout } = useLogout({ onSuccess: () => { setIsLoggingOut(false); }, }); const { isInjectedWallet, injectedAddress } = useInjectedWallet(); + const walletAddress = useWalletAddress(); - const activeWallet = isInjectedWallet - ? { address: injectedAddress, type: "injected_wallet" } - : user?.linkedAccounts.find((account) => account.type === "smart_wallet"); + const activeWallet = { + address: walletAddress, + type: isInjectedWallet ? "injected_wallet" : "smart_wallet", + }; const { handleFundWallet } = useFundWalletHandler("Mobile menu"); - const smartWallet = isInjectedWallet - ? { address: injectedAddress } - : user?.linkedAccounts.find((account) => account.type === "smart_wallet"); + const smartWallet = { address: walletAddress }; const { currentStep } = useStep(); @@ -124,30 +126,34 @@ export const MobileDropdown = ({ description: error.message, }); }, + ensureWalletExists, // Pass the Starknet wallet creation function ); setIsNetworkListOpen(false); }; // Helper function for fallback fetch with timeout - const trackLogoutWithFetch = (payload: { walletAddress: string; logoutMethod: string }) => { + const trackLogoutWithFetch = (payload: { + walletAddress: string; + logoutMethod: string; + }) => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 1500); // 1.5s timeout - fetch('/api/track-logout', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + fetch("/api/track-logout", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), - signal: controller.signal - }) - .catch(error => { - if (error.name !== 'AbortError') { - console.warn('Logout tracking failed:', error); - } + signal: controller.signal, }) - .finally(() => { - clearTimeout(timeoutId); - }); + .catch((error) => { + if (error.name !== "AbortError") { + console.warn("Logout tracking failed:", error); + } + }) + .finally(() => { + clearTimeout(timeoutId); + }); }; const handleLogout = async () => { @@ -289,7 +295,7 @@ export const MobileDropdown = ({ )} - setIsWarningModalOpen(false)} address={smartWallet?.address ?? ""} diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index 17cff2aa..779edb3b 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -27,6 +27,7 @@ import Image from "next/image"; import { useNetwork } from "../context/NetworksContext"; import { useInjectedWallet } from "../context"; import { useActualTheme } from "../hooks/useActualTheme"; +import { useWalletAddress } from "../hooks/useWalletAddress"; export const Navbar = () => { const [mounted, setMounted] = useState(false); @@ -37,12 +38,14 @@ export const Navbar = () => { const { selectedNetwork } = useNetwork(); const { isInjectedWallet, injectedAddress } = useInjectedWallet(); const isDark = useActualTheme(); + const walletAddress = useWalletAddress(); const { ready, authenticated, user } = usePrivy(); - const activeWallet = isInjectedWallet - ? { address: injectedAddress, type: "injected_wallet" } - : user?.linkedAccounts.find((account) => account.type === "smart_wallet"); + const activeWallet = { + address: walletAddress, + type: isInjectedWallet ? "injected_wallet" : "smart_wallet", + }; const { login } = useLogin({ onComplete: async ({ user, isNewUser, loginMethod }) => { diff --git a/app/components/NetworkSelectionModal.tsx b/app/components/NetworkSelectionModal.tsx index afc0ac1e..5ae5722b 100644 --- a/app/components/NetworkSelectionModal.tsx +++ b/app/components/NetworkSelectionModal.tsx @@ -8,6 +8,7 @@ import { HelpCircleIcon, ArrowLeft02Icon, Cancel01Icon } from "hugeicons-react"; import { usePrivy } from "@privy-io/react-auth"; import { networks } from "../mocks"; import { useNetwork } from "../context/NetworksContext"; +import { useStarknet } from "../context"; import { AnimatedModal } from "./AnimatedComponents"; import { shouldUseInjectedWallet, @@ -23,6 +24,7 @@ export const NetworkSelectionModal = () => { const [showInfo, setShowInfo] = useState(false); const [hasCheckedStorage, setHasCheckedStorage] = useState(false); const { selectedNetwork, setSelectedNetwork } = useNetwork(); + const { ensureWalletExists } = useStarknet(); const { authenticated, user } = usePrivy(); const useInjectedWallet = shouldUseInjectedWallet(searchParams); const isDark = useActualTheme(); @@ -59,6 +61,7 @@ export const NetworkSelectionModal = () => { console.error("Failed to switch network:", error); setSelectedNetwork(selectedNetwork); }, + ensureWalletExists, // Pass the Starknet wallet creation function ); } }; diff --git a/app/components/NetworksDropdown.tsx b/app/components/NetworksDropdown.tsx index a82a3aa5..9d7dde86 100644 --- a/app/components/NetworksDropdown.tsx +++ b/app/components/NetworksDropdown.tsx @@ -13,7 +13,7 @@ import { } from "../utils"; import { FlexibleDropdown } from "./FlexibleDropdown"; import { ArrowDown01Icon } from "hugeicons-react"; -import { useNetwork, useStep } from "../context"; +import { useNetwork, useStep, useStarknet } from "../context"; import { useActualTheme } from "../hooks/useActualTheme"; interface NetworksDropdownProps { @@ -27,6 +27,7 @@ export const NetworksDropdown = ({ const { isFormStep } = useStep(); const useInjectedWallet = shouldUseInjectedWallet(searchParams); const isDark = useActualTheme(); + const { ensureWalletExists } = useStarknet(); iconOnly = !isFormStep; @@ -56,6 +57,7 @@ export const NetworksDropdown = ({ description: error.message, }); }, + ensureWalletExists, // Pass the Starknet wallet creation function ); } }; diff --git a/app/components/SettingsDropdown.tsx b/app/components/SettingsDropdown.tsx index 3813b232..4c518fd3 100644 --- a/app/components/SettingsDropdown.tsx +++ b/app/components/SettingsDropdown.tsx @@ -23,13 +23,15 @@ import { import { toast } from "sonner"; import { useInjectedWallet } from "../context"; import { useWalletDisconnect } from "../hooks/useWalletDisconnect"; +import { useWalletAddress } from "../hooks/useWalletAddress"; import { CopyAddressWarningModal } from "./CopyAddressWarningModal"; export const SettingsDropdown = () => { const { user, updateEmail } = usePrivy(); const { showMfaEnrollmentModal } = useMfaEnrollment(); const [isLoggingOut, setIsLoggingOut] = useState(false); - const { isInjectedWallet, injectedAddress } = useInjectedWallet(); + const { isInjectedWallet } = useInjectedWallet(); + const walletAddress = useWalletAddress(); const [isOpen, setIsOpen] = useState(false); const [isAddressCopied, setIsAddressCopied] = useState(false); @@ -41,11 +43,6 @@ export const SettingsDropdown = () => { handler: () => setIsOpen(false), }); - const walletAddress = isInjectedWallet - ? injectedAddress - : user?.linkedAccounts.find((account) => account.type === "smart_wallet") - ?.address; - const handleCopyAddress = () => { navigator.clipboard.writeText(walletAddress ?? ""); setIsAddressCopied(true); @@ -127,6 +124,11 @@ export const SettingsDropdown = () => { } } + localStorage.removeItem(`starknet_walletId_${user?.id}`); + localStorage.removeItem(`starknet_address_${user?.id}`); + localStorage.removeItem(`starknet_publicKey_${user?.id}`); + localStorage.removeItem(`starknet_deployed_${user?.id}`); + await logout(); if (window.ethereum) { await disconnectWallet(); diff --git a/app/components/TransferForm.tsx b/app/components/TransferForm.tsx index aace4fe5..ccd073a3 100644 --- a/app/components/TransferForm.tsx +++ b/app/components/TransferForm.tsx @@ -5,6 +5,7 @@ import { usePrivy } from "@privy-io/react-auth"; import { useSmartWallets } from "@privy-io/react-auth/smart-wallets"; import { useNetwork } from "../context/NetworksContext"; import { useBalance, useTokens } from "../context"; +import { useStarknet } from "../context/StarknetContext"; import { classNames, formatDecimalPrecision, @@ -44,8 +45,9 @@ export const TransferForm: React.FC<{ const { selectedNetwork } = useNetwork(); const { client } = useSmartWallets(); const { user, getAccessToken } = usePrivy(); - const { refreshBalance } = useBalance(); + const { refreshBalance, starknetWalletBalance } = useBalance(); const { allTokens } = useTokens(); + const { walletId, publicKey, address, deployed } = useStarknet(); const useInjectedWallet = shouldUseInjectedWallet(searchParams); const isDark = useActualTheme(); @@ -79,7 +81,8 @@ export const TransferForm: React.FC<{ const { token, amount, recipientNetwork, recipientNetworkImageUrl } = watch(); // Get the Network object for the selected recipient network - const transferNetwork = networks.find(n => n.chain.name === recipientNetwork) || selectedNetwork; + const transferNetwork = + networks.find((n) => n.chain.name === recipientNetwork) || selectedNetwork; const fetchedTokens: Token[] = allTokens[transferNetwork.chain.name] || []; const tokens = fetchedTokens.map((token) => ({ @@ -93,9 +96,7 @@ export const TransferForm: React.FC<{ const recipientNetworks = networks .filter((network) => { if (useInjectedWallet) return true; - return ( - network.chain.name !== "Celo" - ); + return network.chain.name !== "Celo"; }) .map((network) => ({ name: network.chain.name, @@ -112,10 +113,16 @@ export const TransferForm: React.FC<{ error, } = useSmartWalletTransfer({ client: client ?? null, - selectedNetwork: transferNetwork, // Use the recipient's network, not global + selectedNetwork: transferNetwork, // Use the recipient's network, not global user, supportedTokens: fetchedTokens, getAccessToken, + starknetWallet: { + walletId, + publicKey, + address, + deployed, + }, }); useEffect(() => { @@ -159,11 +166,16 @@ export const TransferForm: React.FC<{ setIsBalanceLoading(true); setBalanceError(null); try { - const balance = await fetchBalanceForNetwork( - transferNetwork, - smartWalletAccount.address, - ); - setTransferNetworkBalance(balance); + if (transferNetwork.chain.name === "Starknet") { + setTransferNetworkBalance(starknetWalletBalance); + } else { + const balance = await fetchBalanceForNetwork( + transferNetwork, + smartWalletAccount.address, + ); + + setTransferNetworkBalance(balance); + } setBalanceError(null); } catch (error) { console.error("Error fetching transfer network balance:", error); @@ -352,12 +364,14 @@ export const TransferForm: React.FC<{ message: "Recipient address is required", }, pattern: { - value: /^0x[a-fA-F0-9]{40}$/, + value: /^0x[a-fA-F0-9]{40}$|^0x[a-fA-F0-9]{64}$/, message: "Invalid wallet address format", }, validate: { length: (value) => - value.length === 42 || "Address must be 42 characters long", + value.length === 42 || + value.length === 66 || + "Address must be 42 characters (EVM) or 66 characters (Starknet)", prefix: (value) => value.startsWith("0x") || "Address must start with 0x", }, @@ -369,7 +383,7 @@ export const TransferForm: React.FC<{ : "text-neutral-900 dark:text-white/80", )} placeholder="Enter recipient wallet address" - maxLength={42} + maxLength={66} /> {errors.recipientAddress && ( diff --git a/app/components/WalletDetails.tsx b/app/components/WalletDetails.tsx index 5c012ec1..24f0b4e5 100644 --- a/app/components/WalletDetails.tsx +++ b/app/components/WalletDetails.tsx @@ -20,6 +20,7 @@ import { import Image from "next/image"; import { useFundWalletHandler } from "../hooks/useFundWalletHandler"; import { useInjectedWallet } from "../context"; +import { useWalletAddress } from "../hooks/useWalletAddress"; import { toast } from "sonner"; import { Dialog } from "@headlessui/react"; import { AnimatePresence, motion } from "framer-motion"; @@ -56,6 +57,7 @@ export const WalletDetails = () => { const { isInjectedWallet, injectedAddress } = useInjectedWallet(); const { user } = usePrivy(); const isDark = useActualTheme(); + const walletAddress = useWalletAddress(); // Custom hook for handling wallet funding const { handleFundWallet } = useFundWalletHandler("Wallet details"); @@ -71,14 +73,14 @@ export const WalletDetails = () => { }); // Determine active wallet based on wallet type - const activeWallet = isInjectedWallet - ? { address: injectedAddress } - : user?.linkedAccounts.find((account) => account.type === "smart_wallet"); + const activeWallet = { address: walletAddress }; // Get appropriate balance based on wallet type const activeBalance = isInjectedWallet ? allBalances.injectedWallet - : allBalances.smartWallet; + : selectedNetwork.chain.name === "Starknet" + ? allBalances.starknetWallet + : allBalances.smartWallet; // Handler for funding wallet with specified amount and token const handleFundWalletClick = async ( @@ -228,11 +230,15 @@ export const WalletDetails = () => {
- {formatCurrency( - activeBalance?.total ?? 0, - "USD", - "en-US", - )} + {selectedNetwork.chain.name === "Starknet" + ? // For Starknet, show token amount directly + `$${(activeBalance?.total ?? 0).toFixed(2)}` + : // For mainnet chains, show USD value + formatCurrency( + activeBalance?.total ?? 0, + "USD", + "en-US", + )}