From e9ea639ce938c8eb79e8cfc78d093404ce2d7fbb Mon Sep 17 00:00:00 2001 From: Isaac Onyemaechi Date: Tue, 23 Dec 2025 22:15:30 +0100 Subject: [PATCH 01/14] added starknet Sepolia to noblocks --- .env.example | 22 ++ app/api/starknet/create-wallet/route.ts | 99 ++++++ app/api/starknet/deploy-wallet/route.ts | 89 +++++ app/api/starknet/get-public-key/route.ts | 71 ++++ app/components/MainPageContent.tsx | 70 ++-- app/components/MobileDropdown.tsx | 48 +-- app/components/Navbar.tsx | 9 +- app/components/NetworkSelectionModal.tsx | 3 + app/components/NetworksDropdown.tsx | 4 +- app/components/SettingsDropdown.tsx | 9 +- app/components/WalletDetails.tsx | 34 +- app/context/BalanceContext.tsx | 41 +++ app/context/StarknetContext.tsx | 427 +++++++++++++++++++++++ app/context/TokensContext.tsx | 8 + app/context/index.ts | 1 + app/hooks/useCNGNRate.ts | 15 + app/hooks/useWalletAddress.ts | 29 ++ app/lib/authorization.ts | 71 ++++ app/lib/starknet.ts | 416 ++++++++++++++++++++++ app/mocks.ts | 27 ++ app/pages/TransactionForm.tsx | 11 +- app/providers.tsx | 29 +- app/utils.ts | 167 ++++++++- package.json | 3 +- pnpm-lock.yaml | 210 ++++++++--- public/logos/eth-logo.svg | 13 + public/logos/strk-logo.svg | 7 + 27 files changed, 1794 insertions(+), 139 deletions(-) create mode 100644 app/api/starknet/create-wallet/route.ts create mode 100644 app/api/starknet/deploy-wallet/route.ts create mode 100644 app/api/starknet/get-public-key/route.ts create mode 100644 app/context/StarknetContext.tsx create mode 100644 app/hooks/useWalletAddress.ts create mode 100644 app/lib/authorization.ts create mode 100644 app/lib/starknet.ts create mode 100644 public/logos/eth-logo.svg create mode 100644 public/logos/strk-logo.svg diff --git a/.env.example b/.env.example index db42dfa2..f2814c30 100644 --- a/.env.example +++ b/.env.example @@ -114,6 +114,28 @@ 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-sepolia.public.blastapi.io + +# Ready Account Class Hash (Argent Ready Account) +# Sepolia: 0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f +STARKNET_READY_CLASSHASH=0x036078334509b514626504edc9fb252328d1a240e4e948bef8d0c08dff45927f + +# Paymaster Configuration (AVNU) +# Get API key from: https://app.avnu.fi/ +# Mode: 'sponsored' (dApp pays) or 'default' (user pays with gas token) +STARKNET_PAYMASTER_URL=https://sepolia.paymaster.avnu.fi +STARKNET_PAYMASTER_MODE=sponsored +STARKNET_PAYMASTER_API_KEY= + +# Gas token address (required if PAYMASTER_MODE=default) +# STRK on Sepolia: 0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d +STARKNET_GAS_TOKEN_ADDRESS= + # ============================================================================= # Content Management (Sanity) # ============================================================================= diff --git a/app/api/starknet/create-wallet/route.ts b/app/api/starknet/create-wallet/route.ts new file mode 100644 index 00000000..c0aae646 --- /dev/null +++ b/app/api/starknet/create-wallet/route.ts @@ -0,0 +1,99 @@ +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 { ownerId } = body; + + const userId = ownerId || 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(); + + try { + const user = await privy.getUser(userId); + const linkedAccounts = user.linkedAccounts || []; + const 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); + } + + // 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/deploy-wallet/route.ts b/app/api/starknet/deploy-wallet/route.ts new file mode 100644 index 00000000..eb1ba89e --- /dev/null +++ b/app/api/starknet/deploy-wallet/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifyJWT } from "@/app/lib/jwt"; +import { DEFAULT_PRIVY_CONFIG } from "@/app/lib/config"; +import { + getStarknetWallet, + computeReadyAddress, + buildReadyAccount, + getRpcProvider, + deployReadyAccount +} from "@/app/lib/starknet"; + +/** + * POST /api/starknet/deploy-wallet + * Deploys a Ready account for the user's Starknet wallet + */ +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, publicKey: providedPublicKey } = body; + + if (!walletId) { + return NextResponse.json( + { error: "walletId is required" }, + { status: 400 } + ); + } + + const classHash = process.env.STARKNET_READY_CLASSHASH; + if (!classHash) { + return NextResponse.json( + { error: "STARKNET_READY_CLASSHASH not configured" }, + { status: 500 } + ); + } + + // Get wallet details from Privy + const walletDetails = await getStarknetWallet(walletId, providedPublicKey); + const { publicKey } = walletDetails; + const address = computeReadyAddress(publicKey, classHash); + + // Deploy the Ready account + const origin = request.headers.get("origin") || undefined; + const deployResult = await deployReadyAccount({ + walletId, + publicKey, + classHash, + userJwt: token, + userId: authUserId, + origin, + }); + + return NextResponse.json({ + success: true, + walletId, + address, + publicKey, + transactionHash: deployResult.transaction_hash, + }); + } catch (error: any) { + console.error("Error deploying Starknet wallet:", error); + return NextResponse.json( + { + error: error.message || "Failed to deploy 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..b65622aa --- /dev/null +++ b/app/api/starknet/get-public-key/route.ts @@ -0,0 +1,71 @@ +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 }); + + 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.", + wallet: wallet, + }, { status: 404 }); + } + + return NextResponse.json({ + success: true, + publicKey: publicKey, + wallet: wallet, + }); + + } catch (error: any) { + return NextResponse.json( + { + error: error.message || "Failed to fetch public key", + }, + { status: 500 } + ); + } +} 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 c5fbc468..65a2e8ee 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..d31e1d9a 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); diff --git a/app/components/WalletDetails.tsx b/app/components/WalletDetails.tsx index 5c012ec1..fb58a124 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 Sepolia" + ? 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 Sepolia" + ? // For Starknet testnet, show token amount directly + `$${(activeBalance?.total ?? 0).toFixed(2)}` + : // For mainnet chains, show USD value + formatCurrency( + activeBalance?.total ?? 0, + "USD", + "en-US", + )}