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',
+ };
+ }
+}