diff --git a/README.md b/README.md index 7644eed..7de85ea 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,23 @@ In practical terms: - Stellar asset issuance, account flows, payout tracking, and Soroban contracts are the next chain layer - README language, roadmap, and contribution guidance below are written for the Stellar path +### Wallet modes + +Wallet and chain assumptions live in `lib/wallet/config.ts`; UI-facing wallet shapes live in +`lib/wallet/types.ts`. + +- **Current mode — Privy embedded wallet:** Privy remains the authentication and embedded-wallet + provider. New users receive an EVM wallet on Lisk Sepolia, preserving the existing signup, + provider, and funding flows. +- **Planned mode — Stellar account:** a linked `stellarPublicKey` is displayed as a Stellar Testnet + account in wallet/profile surfaces. This display path is separate from authentication and does + not replace Privy. +- **No linked wallet:** public wallet display helpers return a neutral “Not linked” state instead + of leaking chain-specific fallback assumptions into components. + +When Stellar signing is introduced, add it behind the planned mode rather than changing Privy +authentication or scattering network constants through UI components. + ## Tech Stack - Next.js 16 with the App Router diff --git a/app/Providers.tsx b/app/Providers.tsx index 0e4a331..bf8d82e 100644 --- a/app/Providers.tsx +++ b/app/Providers.tsx @@ -1,46 +1,26 @@ -"use client" - -import type { FC, ReactNode } from "react" -import { liskSepolia } from "viem/chains" - -import { PrivyProvider } from "@/lib/privy/react-auth" -import { WalletProvider } from "@/contexts/wallet-context" -import { getStellarConfig } from "@/lib/stellar/config" -import type { WalletNetwork } from "@/types/wallet" - -const privyAppId = process.env.NEXT_PUBLIC_PRIVY_APP_ID - -function getDefaultNetwork(): WalletNetwork { - const stellarConfig = getStellarConfig() - return stellarConfig.network.toLowerCase() === "mainnet" ? "stellar-mainnet" : "stellar-testnet" -} - -export const Providers: FC<{ children: ReactNode }> = ({ children }) => { - const defaultNetwork = getDefaultNetwork() - - return ( - - - {children} - - - ) -} +"use client" + +import type { FC, ReactNode } from "react" + +import { PrivyProvider } from "@/lib/privy/react-auth" +import { embeddedWalletProviderConfig } from "@/lib/wallet/config" + +const privyAppId = process.env.NEXT_PUBLIC_PRIVY_APP_ID + +function getDefaultNetwork(): WalletNetwork { + const stellarConfig = getStellarConfig() + return stellarConfig.network.toLowerCase() === "mainnet" ? "stellar-mainnet" : "stellar-testnet" +} + +export const Providers: FC<{ children: ReactNode }> = ({ children }) => { + const defaultNetwork = getDefaultNetwork() + + return ( + + {children} + + ) +} diff --git a/app/dashboard/investor/page.tsx b/app/dashboard/investor/page.tsx index a2e9c72..ad6ed55 100644 --- a/app/dashboard/investor/page.tsx +++ b/app/dashboard/investor/page.tsx @@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useState } from "react" import { useRouter } from "next/navigation" import { CalendarDays, ChevronDown, PlusCircle } from "lucide-react" import { formatEther } from "viem" -import { liskSepolia } from "viem/chains" import { DashboardShell } from "@/components/dashboard/dashboard-shell" import { DashboardHeader } from "@/components/dashboard/investor-overview/dashboard-header" @@ -24,6 +23,7 @@ import { formatNaira } from "@/lib/currency" import { isMockStellar } from "@/lib/mock-stellar/mockConfig" import { mockAccount } from "@/lib/mock-stellar/mockAccount" import { mockActivity } from "@/lib/mock-stellar/mockActivity" +import { CURRENT_EMBEDDED_WALLET } from "@/lib/wallet/config" type PoolPreview = { id: string @@ -48,14 +48,16 @@ function truncateAddress(address: string) { } function formatEthForUi(balanceEth: number | null) { - if (!Number.isFinite(balanceEth) || balanceEth === null) return "0 ETH" - if (balanceEth < 0.01) return "0 ETH" - if (balanceEth < 1) return `${balanceEth.toFixed(2)} ETH` - return `${balanceEth.toFixed(1)} ETH` + const asset = CURRENT_EMBEDDED_WALLET.network.nativeAsset + if (!Number.isFinite(balanceEth) || balanceEth === null) return `0 ${asset}` + if (balanceEth < 0.01) return `0 ${asset}` + if (balanceEth < 1) return `${balanceEth.toFixed(2)} ${asset}` + return `${balanceEth.toFixed(1)} ${asset}` } async function resolveOnchainBalanceEth(address: string) { - const rpcUrl = liskSepolia.rpcUrls.default.http[0] + const rpcUrl = CURRENT_EMBEDDED_WALLET.network.rpcUrl + if (!rpcUrl) return null const response = await fetch(rpcUrl, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/components/dashboard/account-settings-form.tsx b/components/dashboard/account-settings-form.tsx index 8580be8..68bdb1f 100644 --- a/components/dashboard/account-settings-form.tsx +++ b/components/dashboard/account-settings-form.tsx @@ -12,7 +12,7 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" import { formatNaira } from "@/lib/currency" -import { StellarLinkForm } from "@/components/dashboard/stellar-link-form" +import { getWalletDisplay } from "@/lib/wallet/config" interface AccountSettingsFormProps { roleLabel: "Driver" | "Investor" @@ -38,6 +38,7 @@ interface AccountProfileResponse { bio: string | null role: string walletAddress: string | null + stellarPublicKey: string | null availableBalance: number totalInvested: number totalReturns: number @@ -86,6 +87,7 @@ export function AccountSettingsForm({ roleLabel, kycHref }: AccountSettingsFormP bio: authUser.bio || null, role: authUser.role || roleLabel.toLowerCase(), walletAddress: authUser.walletAddress || null, + stellarPublicKey: authUser.stellarPublicKey || null, availableBalance: Number(authUser.availableBalance || 0), totalInvested: Number(authUser.totalInvested || 0), totalReturns: Number(authUser.totalReturns || 0), @@ -126,6 +128,10 @@ export function AccountSettingsForm({ roleLabel, kycHref }: AccountSettingsFormP }, [authUser?.id, loadProfile]) const isEmailManagedExternally = Boolean(profile?.privyUserId || authUser?.privyUserId) + const walletDisplay = getWalletDisplay({ + embeddedWalletAddress: profile?.walletAddress || authUser?.walletAddress, + stellarPublicKey: profile?.stellarPublicKey || authUser?.stellarPublicKey, + }) const handleSave = async () => { if (!form.fullName.trim()) { @@ -272,7 +278,8 @@ export function AccountSettingsForm({ roleLabel, kycHref }: AccountSettingsFormP

Role: {profile?.role || authUser?.role || roleLabel.toLowerCase()}

-

Wallet: {profile?.walletAddress || authUser?.walletAddress || "Not linked"}

+

{walletDisplay.addressLabel}: {walletDisplay.address || "Not linked"}

+

Network: {walletDisplay.networkLabel}

Internal balance: {formatNaira(Number(profile?.availableBalance || authUser?.availableBalance || 0))}

diff --git a/components/dashboard/investor-wallet-panel.tsx b/components/dashboard/investor-wallet-panel.tsx index 1fa229a..c629778 100644 --- a/components/dashboard/investor-wallet-panel.tsx +++ b/components/dashboard/investor-wallet-panel.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useState } from "react" import { usePathname, useRouter, useSearchParams } from "next/navigation" import { formatEther } from "viem" -import { liskSepolia } from "viem/chains" import { ArrowRight, CheckCircle2, @@ -31,6 +30,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Skeleton } from "@/components/ui/skeleton" +import { CURRENT_EMBEDDED_WALLET, getWalletDisplay, shortenWalletAddress } from "@/lib/wallet/config" import { Table, TableBody, @@ -66,11 +66,6 @@ interface InvestorWalletPanelProps { showTitle?: boolean } -function truncateAddress(address: string) { - if (address.length < 10) return address - return `${address.slice(0, 6)}...${address.slice(-4)}` -} - function toPaystackAmount(value: string) { const parsed = Number.parseFloat(value) if (!Number.isFinite(parsed)) return null @@ -87,7 +82,8 @@ function isValidEmail(value: string) { } async function resolveOnchainBalance(address: string) { - const rpcUrl = liskSepolia.rpcUrls.default.http[0] + const rpcUrl = CURRENT_EMBEDDED_WALLET.network.rpcUrl + if (!rpcUrl) return null const response = await fetch(rpcUrl, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -104,7 +100,7 @@ async function resolveOnchainBalance(address: string) { const balance = Number.parseFloat(formatEther(BigInt(payload.result))) if (!Number.isFinite(balance)) return null - return `${balance.toFixed(4)} ETH` + return `${balance.toFixed(4)} ${CURRENT_EMBEDDED_WALLET.network.nativeAsset}` } export function InvestorWalletPanel({ sectionId = "wallet", className, showTitle = true }: InvestorWalletPanelProps) { @@ -133,6 +129,10 @@ export function InvestorWalletPanel({ sectionId = "wallet", className, showTitle ) const walletAddress = isMockStellar ? mockAccount.publicKey : (walletSummary?.wallet.walletAddress || embeddedWallet?.address || authUser?.walletAddress || "") + const walletDisplay = getWalletDisplay({ + embeddedWalletAddress: walletAddress, + stellarPublicKey: isMockStellar ? mockAccount.publicKey : authUser?.stellarPublicKey, + }) const internalBalance = walletSummary?.wallet.internalBalanceNgn || 0 const fundingTransactions = useMemo(() => { @@ -224,12 +224,12 @@ export function InvestorWalletPanel({ sectionId = "wallet", className, showTitle ) const openWalletExplorer = () => { - if (!walletAddress) return - window.open(`https://sepolia-blockscout.lisk.com/address/${walletAddress}`, "_blank", "noopener,noreferrer") + if (!walletDisplay.explorerUrl) return + window.open(walletDisplay.explorerUrl, "_blank", "noopener,noreferrer") } const handleOpenWalletView = () => { - if (!walletAddress) { + if (!walletDisplay.explorerUrl) { toast({ title: "Wallet unavailable", description: "Your embedded wallet address is not ready yet. Please sign out and sign in again.", @@ -362,8 +362,8 @@ export function InvestorWalletPanel({ sectionId = "wallet", className, showTitle } const handleCopyAddress = async () => { - if (!walletAddress) return - await navigator.clipboard.writeText(walletAddress) + if (!walletDisplay.address) return + await navigator.clipboard.writeText(walletDisplay.address) toast({ title: "Address copied", description: "Wallet address copied to clipboard.", @@ -510,14 +510,15 @@ export function InvestorWalletPanel({ sectionId = "wallet", className, showTitle
-

Wallet address

-

{walletAddress ? truncateAddress(walletAddress) : "Not available"}

+

{walletDisplay.addressLabel}

+

{walletDisplay.address ? shortenWalletAddress(walletDisplay.address) : "Not available"}

+

{walletDisplay.networkLabel}

- - @@ -539,7 +540,7 @@ export function InvestorWalletPanel({ sectionId = "wallet", className, showTitle

{onchainLoading ? "Loading..." : onchainBalance || "Unavailable"}

)}

- {isMockStellar ? "Stellar Mock Assets" : "Lisk Sepolia embedded wallet"} + {isMockStellar ? "Stellar Mock Assets" : `${CURRENT_EMBEDDED_WALLET.network.label} embedded wallet`}

diff --git a/components/dashboard/wallet-menu.tsx b/components/dashboard/wallet-menu.tsx index d899274..0edc0c2 100644 --- a/components/dashboard/wallet-menu.tsx +++ b/components/dashboard/wallet-menu.tsx @@ -4,7 +4,6 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" import { ChevronRight, Copy, ExternalLink, Loader2, Wallet } from "lucide-react" import { formatEther } from "viem" -import { liskSepolia } from "viem/chains" import { Button } from "@/components/ui/button" import { @@ -20,14 +19,11 @@ import { useAuth } from "@/hooks/use-auth" import { getPrivyFundingErrorMessage, startPrivyFunding } from "@/lib/auth/privy-funding" import { useToast } from "@/components/ui/use-toast" import { useFundWallet, useWallets } from "@/lib/privy/react-auth" - -function truncateAddress(address: string) { - if (address.length < 10) return address - return `${address.slice(0, 6)}...${address.slice(-4)}` -} - -async function resolveOnchainBalance(address: string) { - const rpcUrl = liskSepolia.rpcUrls.default.http[0] +import { CURRENT_EMBEDDED_WALLET, getWalletDisplay } from "@/lib/wallet/config" + +async function resolveOnchainBalance(address: string) { + const rpcUrl = CURRENT_EMBEDDED_WALLET.network.rpcUrl + if (!rpcUrl) return null const response = await fetch(rpcUrl, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -46,7 +42,7 @@ async function resolveOnchainBalance(address: string) { const balance = formatEther(BigInt(balanceHex)) const parsed = Number.parseFloat(balance) if (!Number.isFinite(parsed)) return null - return `${parsed.toFixed(4)} ETH` + return `${parsed.toFixed(4)} ${CURRENT_EMBEDDED_WALLET.network.nativeAsset}` } export function WalletMenu() { @@ -65,7 +61,11 @@ export function WalletMenu() { return wallets.find((wallet) => wallet.walletClientType === "privy" || wallet.walletClientType === "privy-v2") }, [wallets]) - const walletAddress = embeddedWallet?.address || authUser?.walletAddress || "" + const walletAddress = embeddedWallet?.address || authUser?.walletAddress || "" + const walletDisplay = getWalletDisplay({ + embeddedWalletAddress: walletAddress, + stellarPublicKey: authUser?.stellarPublicKey, + }) const internalBalance = authUser?.availableBalance || 0 const isBalanceLoading = Boolean(walletAddress) && onchainBalance.address !== walletAddress @@ -93,17 +93,17 @@ export function WalletMenu() { } }, [walletAddress]) - const copyAddress = async () => { - if (!walletAddress) return - await navigator.clipboard.writeText(walletAddress) - toast({ - title: "Address copied", - description: "Wallet address copied to clipboard.", + const copyAddress = async () => { + if (!walletDisplay.address) return + await navigator.clipboard.writeText(walletDisplay.address) + toast({ + title: "Address copied", + description: `${walletDisplay.addressLabel} copied to clipboard.`, }) } const handleOpenWalletView = () => { - if (!walletAddress) { + if (!walletDisplay.explorerUrl) { toast({ title: "No wallet address", description: "Sign in again to initialize your embedded wallet.", @@ -112,7 +112,7 @@ export function WalletMenu() { return } - window.open(`https://sepolia-blockscout.lisk.com/address/${walletAddress}`, "_blank", "noopener,noreferrer") + window.open(walletDisplay.explorerUrl, "_blank", "noopener,noreferrer") toast({ title: "Wallet address ready", description: "Use your address to receive funds, or continue with Paystack for NGN wallet funding.", @@ -157,12 +157,15 @@ export function WalletMenu() { - Wallet + + {walletDisplay.label} + {walletDisplay.networkLabel} +
@@ -171,7 +174,7 @@ export function WalletMenu() {

{formatNaira(internalBalance)}

-

Onchain balance

+

{CURRENT_EMBEDDED_WALLET.network.label} balance

{!walletAddress ? "Unavailable" : isBalanceLoading ? "Loading..." : onchainBalance.value || "Unavailable"}

@@ -180,8 +183,8 @@ export function WalletMenu() { event.preventDefault()} className="cursor-default">
- {walletAddress || "No wallet address"} -
@@ -202,7 +205,7 @@ export function WalletMenu() { event.preventDefault()} className="cursor-default">