diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index bedd796..cb0517f 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -1,29 +1,19 @@ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useCallback } from 'react'; import { View, Text, StyleSheet, ScrollView, RefreshControl } from 'react-native'; import { useRouter } from 'expo-router'; import { useWalletStore } from '../../src/store/walletStore'; import { SIZES, RADIUS, ThemeColors } from '../../src/constants/theme'; import { useTheme } from '../../src/hooks/useTheme'; import { Button } from '../../src/components/Button'; -import { FundButton } from '../../src/components/FundButton'; import { TransactionListItem } from '../../src/components/TransactionListItem'; import { NetworkStatusBanner } from '../../src/components/NetworkStatusBanner'; import { WalletEmptyState } from '../../src/components/WalletEmptyState'; +import { BalanceDisplay } from '../../src/components/BalanceDisplay'; +import { FundingStatusBanner } from '../../src/components/FundingStatusBanner'; import { useNetworkStatus } from '../../src/hooks/useNetworkStatus'; -import { Clock, RefreshCw } from 'lucide-react-native'; -import { formatAmount } from '../../src/utils/amount'; +import { Clock } from 'lucide-react-native'; import { BackupReminderModal } from '../../src/components/BackupReminderModal'; -function formatRelativeTime(timestamp: number): string { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - if (seconds < 60) return 'just now'; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return `${Math.floor(hours / 24)}d ago`; -} - export default function HomeScreen() { const router = useRouter(); const { colors } = useTheme(); @@ -37,8 +27,11 @@ export default function HomeScreen() { isFunding, fundError, error, + balanceState, + fundingStatus, refreshWalletData, fundWallet, + checkFundingStatus, showBackupReminder, acknowledgeBackupReminder, } = useWalletStore(); @@ -47,9 +40,16 @@ export default function HomeScreen() { useEffect(() => { refreshWalletData(); + checkFundingStatus(); }, []); - const isFunded = balance !== '0.0000000'; + const handleRetry = useCallback(() => { + if (publicKey) { + refreshWalletData(); + checkFundingStatus(); + } + }, [publicKey, refreshWalletData, checkFundingStatus]); + const recentTransactions = transactions.slice(0, 3); // Preview if (!publicKey) { @@ -71,7 +71,7 @@ export default function HomeScreen() { refreshControl={ } @@ -83,27 +83,22 @@ export default function HomeScreen() { isRetrying={isLoading} /> - - Total Balance (Testnet) - {formatAmount(balance)} XLM - - {publicKey} - - {lastRefreshed !== null && ( - - - - Updated {formatRelativeTime(lastRefreshed)} - - - )} - + {/* Issue #329: Balance display with all states */} + - @@ -162,44 +157,6 @@ const createStyles = (colors: ThemeColors) => StyleSheet.create({ backgroundColor: colors.background, padding: SIZES.lg, }, - balanceCard: { - backgroundColor: colors.surface, - padding: SIZES.xl, - borderRadius: RADIUS.lg, - alignItems: 'center', - marginBottom: SIZES.xl, - borderWidth: 1, - borderColor: colors.border, - }, - balanceLabel: { - color: colors.textSecondary, - fontSize: 14, - marginBottom: SIZES.xs, - }, - balanceValue: { - color: colors.textPrimary, - fontSize: 36, - fontWeight: 'bold', - marginBottom: SIZES.sm, - }, - publicKey: { - color: colors.textMuted, - fontSize: 12, - backgroundColor: colors.background, - paddingHorizontal: SIZES.md, - paddingVertical: SIZES.xs, - borderRadius: RADIUS.round, - }, - lastRefreshed: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - marginTop: SIZES.sm, - }, - lastRefreshedText: { - color: colors.textMuted, - fontSize: 11, - }, actionsContainer: { flexDirection: 'row', justifyContent: 'space-between', diff --git a/app/send.tsx b/app/send.tsx index 537eff6..ace342f 100644 --- a/app/send.tsx +++ b/app/send.tsx @@ -31,6 +31,7 @@ import { ScanLine, ChevronDown, User, + Info, } from "lucide-react-native"; import { ScreenHeader } from "@/components"; @@ -50,10 +51,14 @@ export default function SendScreen() { const router = useRouter(); const { colors } = useTheme(); const styles = useMemo(() => createStyles(colors), [colors]); - const { publicKey, getSecretKey, refreshWalletData, balance } = + const { publicKey, getSecretKey, refreshWalletData, balance, fundingStatus } = useWalletStore(); const contacts = useAppStore((state) => state.contacts); + // Issue #330: Disable send when account is unfunded + const isUnfunded = fundingStatus === 'unfunded'; + const sendDisabled = isUnfunded || !publicKey; + const [destination, setDestination] = useState(""); const [amount, setAmount] = useState(""); const [memo, setMemo] = useState(""); @@ -156,6 +161,16 @@ export default function SendScreen() { subtitle={`Available Balance: ${formatAmount(balance)} XLM`} /> + {/* Issue #330: Show unfunded account warning */} + {isUnfunded && ( + + + + Your account has not been funded yet. Fund it with Friendbot on the home screen before sending. + + + )} + @@ -334,7 +350,23 @@ const createStyles = (colors: ThemeColors) => fontSize: 13, fontWeight: "500", }, - sendButton: { - marginBottom: SIZES.xxl, - }, + unfundedWarning: { + flexDirection: 'row', + alignItems: 'flex-start', + backgroundColor: 'rgba(255, 196, 0, 0.1)', + padding: SIZES.md, + borderRadius: RADIUS.md, + borderWidth: 1, + borderColor: 'rgba(255, 196, 0, 0.25)', + marginBottom: SIZES.md, + }, + unfundedWarningText: { + flex: 1, + color: colors.warning, + fontSize: 13, + lineHeight: 18, + }, + sendButton: { + marginBottom: SIZES.xxl, + }, }); diff --git a/docs/account-funding-states.md b/docs/account-funding-states.md new file mode 100644 index 0000000..1aab00c --- /dev/null +++ b/docs/account-funding-states.md @@ -0,0 +1,71 @@ +# Account Funding & Balance States + +This document describes the mobile client's handling of account funding status and balance availability states. + +## Account Funding States (Issue #330) + +The app tracks whether the account exists on the Stellar network using four states: + +| State | Meaning | UI Treatment | +|-------------|--------------------------------------------|---------------------------------------------| +| `unknown` | Haven't checked yet (network error) | Show info banner, no action | +| `checking` | In the process of checking | Loading spinner + message | +| `unfunded` | Account does not exist on the network | Show Friendbot funding card | +| `funded` | Account exists on the network | No banner (or minimal success indicator) | + +### Detection + +Funding status is determined by `walletStore.checkFundingStatus()` which calls `fetchAccountDetails()`: + +- **404 / "Not Found"** → account doesn't exist → `unfunded` +- **Network error** → can't determine → `unknown` (user may retry) +- **Success** → account exists → `funded` + +### Impact on Payment Actions + +| State | Send Payment | Receive | Vault Actions | +|-------------|--------------|---------|---------------| +| `unknown` | Allowed | Allowed | Depends | +| `checking` | Allowed | Allowed | Depends | +| `unfunded` | **Blocked** | Allowed | Blocked* | +| `funded` | Allowed | Allowed | Allowed | + +\* Vault deposit/withdraw/lock are also blocked by the vault capability gate when there is no wallet. + +### Friendbot Testnet Funding + +The `FundWallet` action calls `friendbot.stellar.org` to fund the testnet account. This is only available on Testnet. + +## Balance States (Issue #329) + +The app distinguishes between four balance states to avoid conflating "no data" with "zero XLM": + +| State | Meaning | UI Treatment | +|----------------|-------------------------------------------|---------------------------------------------------| +| `idle` | No fetch attempted yet | Spinner + "No balance data yet" | +| `loading` | Fetch in progress | Spinner + "Loading your balance" | +| `available` | Balance fetched (zero or positive) | Show numeric balance (muted if zero) | +| `unavailable` | Network error, failed fetch | Warning card + Retry button | + +### Balance Display Component + +`BalanceDisplay` (`src/components/BalanceDisplay.tsx`) renders a card with: + +- **idle/loading**: Activity spinner with descriptive text +- **available (positive)**: Bold numeric balance with public key and last-refreshed timestamp +- **available (zero)**: Same as positive but muted with a banner explaining zero balance +- **unavailable**: Warning-styled card with error message and retry button + +### Recovery + +When balance is unavailable, the user can: +1. Pull-to-refresh on the home screen +2. Tap the "Retry" button on the balance card +3. Navigate away and back to trigger a new fetch + +## Related Documentation + +- [Vault SDK Capability Assumptions](./vault-sdk-capability-assumptions.md) +- [Vault Integration Assumptions](./vault-integration-assumptions.md) +- [Vault UI Guidance](./vault-ui-guidance.md) +- [Mobile Security Checklist](./mobile-security-checklist.md) diff --git a/src/components/FundingStatusBanner.tsx b/src/components/FundingStatusBanner.tsx new file mode 100644 index 0000000..4762a55 --- /dev/null +++ b/src/components/FundingStatusBanner.tsx @@ -0,0 +1,244 @@ +/** + * FundingStatusBanner + * + * Displays the account funding status and guides the user on next steps. + * + * States: + * - 'unknown': haven't checked whether the account exists on network yet + * - 'checking': in the process of checking account existence + * - 'unfunded': account does not exist on the Stellar network yet + * - 'funded': account exists on the network (may have zero or positive balance) + * + * Powered by Friendbot for Testnet funding. + * + * Accessibility: uses accessibilityRole="alert" when unfunded so screen + * readers announce the state. + */ + +import React, { useMemo } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native'; +import { Zap, AlertTriangle, Loader2, CheckCircle, Info } from 'lucide-react-native'; +import { SIZES, RADIUS, ThemeColors } from '../constants/theme'; +import { useTheme } from '../hooks/useTheme'; +import { AsyncActionButton } from './AsyncActionButton'; +import type { FundingStatus } from '../types/balance'; + +export interface FundingStatusBannerProps { + /** The current funding status of the account. */ + status: FundingStatus; + /** Called when the user taps the fund button. */ + onFund?: () => void; + /** True while a Friendbot funding request is in progress. */ + isFunding?: boolean; + /** Error message from a failed funding attempt. */ + fundError?: string | null; + /** Whether the testnet notice should be shown. */ + showTestnetNotice?: boolean; +} + +export interface FundingStatusCopy { + icon: React.ReactNode; + title: string; + message: string; + /** Whether to show the fund button. */ + showFundButton: boolean; + /** The fund button label. */ + fundLabel: string; +} + +function getStatusCopy( + status: FundingStatus, + colors: ThemeColors +): FundingStatusCopy { + switch (status) { + case 'unknown': + return { + icon: , + title: 'Checking account status…', + message: 'Verifying whether your account exists on the Stellar network.', + showFundButton: false, + fundLabel: '', + }; + case 'checking': + return { + icon: , + title: 'Checking account status…', + message: 'Verifying whether your account exists on the Stellar network.', + showFundButton: false, + fundLabel: '', + }; + case 'unfunded': + return { + icon: , + title: 'Unfunded Testnet Account', + message: + 'This account has not been funded yet. Tap below to receive Testnet XLM from the Stellar Friendbot — free, no real value.', + showFundButton: true, + fundLabel: 'Fund with Friendbot', + }; + case 'funded': + return { + icon: , + title: 'Account funded', + message: 'Your account exists on the Stellar network and is ready to transact.', + showFundButton: false, + fundLabel: '', + }; + } +} + +export const FundingStatusBanner: React.FC = ({ + status, + onFund, + isFunding = false, + fundError, + showTestnetNotice = true, +}) => { + const { colors } = useTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + const copy = useMemo(() => getStatusCopy(status, colors), [status, colors]); + + if (status === 'funded') { + // Funded accounts don't need the banner unless we want to show testnet info + if (!showTestnetNotice) return null; + return ( + + + {copy.icon} + + {copy.title} + {copy.message} + + + + ); + } + + if (status === 'unknown' || status === 'checking') { + return ( + + + {status === 'checking' ? ( + + ) : ( + copy.icon + )} + + {copy.title} + {copy.message} + + + + ); + } + + // Unfunded state — the main banner + return ( + + + {copy.icon} + {copy.title} + + + {copy.message} + + {fundError ? ( + + + {fundError} + + ) : null} + + {copy.showFundButton && onFund ? ( + + ) : null} + + ); +}; + +const createStyles = (colors: ThemeColors) => + StyleSheet.create({ + container: { + marginBottom: SIZES.lg, + borderRadius: RADIUS.lg, + padding: SIZES.lg, + borderWidth: 1, + }, + unfundedContainer: { + backgroundColor: colors.surface, + borderColor: colors.warning, + }, + fundedContainer: { + backgroundColor: 'rgba(0, 230, 118, 0.06)', + borderColor: 'rgba(0, 230, 118, 0.25)', + }, + row: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + textContainer: { + flex: 1, + marginLeft: SIZES.sm, + }, + title: { + color: colors.textPrimary, + fontSize: 15, + fontWeight: '600', + marginBottom: 2, + }, + message: { + color: colors.textSecondary, + fontSize: 13, + lineHeight: 18, + }, + fundedTitle: { + color: colors.success, + }, + fundedMessage: { + color: colors.textSecondary, + }, + unfundedHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: SIZES.sm, + }, + unfundedTitle: { + color: colors.warning, + fontSize: 16, + fontWeight: '600', + marginLeft: SIZES.sm, + }, + unfundedMessage: { + color: colors.textSecondary, + fontSize: 14, + lineHeight: 20, + marginBottom: SIZES.md, + }, + errorBox: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(255, 61, 0, 0.1)', + borderRadius: RADIUS.sm, + padding: SIZES.sm, + marginBottom: SIZES.md, + }, + errorText: { + color: colors.error, + fontSize: 13, + flex: 1, + }, + fundButton: { + marginTop: SIZES.xs, + }, + }); diff --git a/src/components/index.ts b/src/components/index.ts index f82c7e8..a8609e5 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -16,3 +16,7 @@ export { SigningConfirmModal } from "./SigningConfirmModal"; export { VaultUnavailableState } from "./VaultUnavailableState"; export { StatusBadge } from "./StatusBadge"; export type { BadgeTone } from "./StatusBadge"; +export { BalanceDisplay } from "./BalanceDisplay"; +export type { BalanceDisplayProps } from "./BalanceDisplay"; +export { FundingStatusBanner } from "./FundingStatusBanner"; +export type { FundingStatus, FundingStatusBannerProps } from "./FundingStatusBanner"; diff --git a/src/store/walletStore.ts b/src/store/walletStore.ts index a5e942f..18c96e1 100644 --- a/src/store/walletStore.ts +++ b/src/store/walletStore.ts @@ -2,7 +2,8 @@ import { create } from 'zustand'; import * as SecureStore from 'expo-secure-store'; import AsyncStorage from '@react-native-async-storage/async-storage'; import * as StellarSdk from '@stellar/stellar-sdk'; -import { fetchXlmBalance, fetchTransactionsPage, fundWithFriendbot, PaymentRecord } from '../services/stellar'; +import { fetchXlmBalance, fetchTransactionsPage, fetchAccountDetails, fundWithFriendbot, PaymentRecord } from '../services/stellar'; +import type { BalanceState, FundingStatus } from '../types/balance'; import { CLEAR_WALLET_ERROR, PERSIST_WALLET_ERROR, @@ -43,16 +44,19 @@ interface WalletState { /** True once the initial `loadWalletFromStorage` call has resolved (success or failure). */ walletChecked: boolean; + // ---- Issue #329: Balance state ---- + /** Tracks the lifecycle of the balance fetch — not just its value. */ + balanceState: BalanceState; + + // ---- Issue #330: Account funding status ---- + /** Whether the account exists on the Stellar network. */ + fundingStatus: FundingStatus; + // Pagination isLoadingMore: boolean; hasMoreTransactions: boolean; nextCursor: string | null; - // Pagination - nextCursor: string | null; - hasMoreTransactions: boolean; - isLoadingMore: boolean; - // Actions setWallet: (publicKey: string, secretKey: string) => Promise; loadWalletFromStorage: () => Promise; @@ -68,6 +72,8 @@ interface WalletState { markBackupPending: () => Promise; /** Marks the backup reminder as acknowledged and persists that state. */ acknowledgeBackupReminder: () => Promise; + /** Check whether the account exists on the Stellar network (funding check). */ + checkFundingStatus: () => Promise; } const resetWalletState = () => ({ @@ -124,6 +130,8 @@ export const useWalletStore = create((set, get) => ({ isFunding: false, fundError: null, error: null, + balanceState: 'idle' as BalanceState, + fundingStatus: 'unknown' as FundingStatus, isLoadingMore: false, hasMoreTransactions: false, nextCursor: null, @@ -208,7 +216,7 @@ export const useWalletStore = create((set, get) => ({ const { publicKey } = get(); if (!publicKey) return; - set({ isLoading: true, error: null, isLoadingMore: false, nextCursor: null, hasMoreTransactions: false }); + set({ isLoading: true, error: null, balanceState: 'loading', isLoadingMore: false, nextCursor: null, hasMoreTransactions: false }); try { const [balance, page] = await Promise.all([ fetchXlmBalance(publicKey), @@ -228,6 +236,7 @@ export const useWalletStore = create((set, get) => ({ Object.entries(get().pendingTransactions).filter(([hash]) => !confirmedHashes.has(hash)) ); + const isZero = balance === '0.0000000'; set({ balance, transactions: [...Object.values(remainingPending), ...page.records], @@ -236,10 +245,17 @@ export const useWalletStore = create((set, get) => ({ hasMoreTransactions: page.hasMore, lastRefreshed: Date.now(), isLoading: false, + balanceState: 'available', + // Also update funding status: if we got balance data, the account exists + fundingStatus: 'funded', }); } catch (err: any) { console.error('Failed to refresh wallet data'); - set({ isLoading: false, error: err.message || 'Failed to sync data' }); + set({ + isLoading: false, + balanceState: 'unavailable', + error: err.message || 'Failed to sync data', + }); } }, @@ -337,10 +353,35 @@ export const useWalletStore = create((set, get) => ({ await fundWithFriendbot(publicKey); // Refresh balance after successful funding await get().refreshWalletData(); - set({ isFunding: false }); + set({ isFunding: false, fundingStatus: 'funded' }); } catch (err: any) { console.error('Friendbot funding failed:', err); set({ isFunding: false, fundError: err.message || 'Funding failed. Please try again.' }); } - } + }, + + checkFundingStatus: async () => { + const { publicKey } = get(); + if (!publicKey) { + set({ fundingStatus: 'unknown' }); + return; + } + + set({ fundingStatus: 'checking' }); + try { + await fetchAccountDetails(publicKey); + // Account exists on the network + set({ fundingStatus: 'funded' }); + } catch (err: any) { + const message = err?.message || ''; + if (/not found|404/i.test(message)) { + // Account doesn't exist yet — not funded + set({ fundingStatus: 'unfunded' }); + } else { + // Network error — we can't determine the status + console.error('Failed to check funding status:', err); + set({ fundingStatus: 'unknown' }); + } + } + }, })); diff --git a/src/types/balance.ts b/src/types/balance.ts new file mode 100644 index 0000000..4cc5769 --- /dev/null +++ b/src/types/balance.ts @@ -0,0 +1,66 @@ +/** + * Balance and funding state types for the wallet. + * + * Distinguishes between loading, unavailable (network error), zero balance, + * and known positive balance so the UI can render distinct states instead of + * conflating "no data" with "zero XLM". + * + * FundingStatus tracks whether the account exists on the Stellar network. + */ + +/** The high-level state of the balance fetch lifecycle. */ +export type BalanceState = + | 'idle' // No fetch has been attempted yet (e.g. boot before wallet check) + | 'loading' // A fetch is in-flight + | 'available' // Balance was fetched successfully (may be zero or positive) + | 'unavailable'; // The last fetch failed (network error, Horizon error, etc.) + +/** Human-readable description for each balance state. */ +export interface BalanceStateCopy { + title: string; + message: string; + /** A short label for a retry button action, or undefined if retry doesn't apply. */ + retryLabel?: string; +} + +/** + * Returns user-facing copy for each balance state. + * Safe to render directly — no raw error messages are surfaced. + */ +/** + * Whether the account exists on the Stellar network. + * - 'unknown': haven't checked yet (or network error prevented check) + * - 'checking': in the process of checking account existence + * - 'unfunded': account does not exist on the network yet + * - 'funded': account exists on the network + */ +export type FundingStatus = 'unknown' | 'checking' | 'unfunded' | 'funded'; + +export function describeBalanceState(state: BalanceState): BalanceStateCopy { + switch (state) { + case 'idle': + return { + title: 'No balance data yet', + message: + 'Your wallet balance has not been checked yet. Pull down to refresh.', + retryLabel: 'Refresh', + }; + case 'loading': + return { + title: 'Loading your balance', + message: 'Fetching your latest balance from the Stellar network…', + }; + case 'available': + return { + title: 'Balance available', + message: '', + }; + case 'unavailable': + return { + title: 'Balance unavailable', + message: + 'We could not retrieve your current balance due to a network issue. Your funds are safe. Please try again.', + retryLabel: 'Retry', + }; + } +}