diff --git a/.env.example b/.env.example index 43c326b..815dd3c 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,7 @@ EXPO_PUBLIC_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org # Deployed savings vault contract ID (C...). Leave empty to run the vault # screen in mock mode no real funds are moved until this is set. EXPO_PUBLIC_VAULT_CONTRACT_ID= + +# Set to 'false' to disable the vault tab UI (e.g., when SDK is not ready) +EXPO_PUBLIC_VAULT_ENABLED=true + diff --git a/.gitignore b/.gitignore index de0ac0c..8e16154 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,14 @@ yarn-error.* # generated native folders /ios /android + +# spec-driven development documentation +PRD.md +Architecture.md +Tasks.md +task.md +Memory.md +Handoff.md + + + diff --git a/__tests__/VaultUnavailableState.test.tsx b/__tests__/VaultUnavailableState.test.tsx new file mode 100644 index 0000000..c564291 --- /dev/null +++ b/__tests__/VaultUnavailableState.test.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import { VaultUnavailableState } from '../src/components/VaultUnavailableState'; + +jest.mock('lucide-react-native', () => ({ + XCircle: () => null, +})); + +describe('VaultUnavailableState', () => { + it('renders the title and primary message for no-wallet reason', () => { + const { getByText } = render( + + ); + + expect(getByText('Vault Unavailable')).toBeTruthy(); + expect(getByText('Create or import a wallet to use the Soroban Savings Vault.')).toBeTruthy(); + }); + + it('renders detail boxes for multiple reasons', () => { + const { getByTestId, getByText } = render( + + ); + + expect(getByTestId('reason-detail-feature-disabled')).toBeTruthy(); + expect(getByTestId('reason-detail-no-wallet')).toBeTruthy(); + expect(getByText('EXPO_PUBLIC_VAULT_ENABLED is set to false.')).toBeTruthy(); + }); + + it('calls onNavigateToSettings when Go to Settings button is pressed', () => { + const onNavigateToSettings = jest.fn(); + const { getByText } = render( + + ); + + const button = getByText('Go to Settings'); + fireEvent.press(button); + expect(onNavigateToSettings).toHaveBeenCalledTimes(1); + }); + + it('calls onRetry when Try Again button is pressed for feature-disabled reason', () => { + const onRetry = jest.fn(); + const { getByText } = render( + + ); + + const button = getByText('Try Again'); + fireEvent.press(button); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('renders the docs reference link', () => { + const { getByText } = render( + + ); + + expect(getByText('See docs/vault-ui-guidance.md for more information.')).toBeTruthy(); + }); +}); diff --git a/__tests__/home.pullToRefresh.test.tsx b/__tests__/home.pullToRefresh.test.tsx index df4e6e4..d0388de 100644 --- a/__tests__/home.pullToRefresh.test.tsx +++ b/__tests__/home.pullToRefresh.test.tsx @@ -45,6 +45,11 @@ jest.mock('lucide-react-native', () => ({ ArrowDownLeft: () => null, WifiOff: () => null, RefreshCw: () => null, + ShieldAlert: () => null, + CheckSquare: () => null, + Square: () => null, + Info: () => null, + X: () => null, })); import { useWalletStore } from '../src/store/walletStore'; @@ -114,29 +119,29 @@ describe('AC-W1 – refreshWalletData on mount', () => { describe('AC-W2, AC-W3, AC-W4 – RefreshControl props', () => { it('attaches a RefreshControl to the ScrollView', () => { setup(); - const { UNSAFE_getByType } = render(); - const scrollView = UNSAFE_getByType(ScrollView); + const { UNSAFE_getAllByType } = render(); + const scrollView = UNSAFE_getAllByType(ScrollView)[0]; expect(scrollView.props.refreshControl).toBeDefined(); }); it('passes refreshing=false to RefreshControl when not loading', () => { setup({ isLoading: false }); - const { UNSAFE_getByType } = render(); - const scrollView = UNSAFE_getByType(ScrollView); + const { UNSAFE_getAllByType } = render(); + const scrollView = UNSAFE_getAllByType(ScrollView)[0]; expect(scrollView.props.refreshControl.props.refreshing).toBe(false); }); it('passes refreshing=true to RefreshControl while loading', () => { setup({ isLoading: true }); - const { UNSAFE_getByType } = render(); - const scrollView = UNSAFE_getByType(ScrollView); + const { UNSAFE_getAllByType } = render(); + const scrollView = UNSAFE_getAllByType(ScrollView)[0]; expect(scrollView.props.refreshControl.props.refreshing).toBe(true); }); it('calls refreshWalletData when onRefresh fires', () => { const store = setup({ isLoading: false }); - const { UNSAFE_getByType } = render(); - const scrollView = UNSAFE_getByType(ScrollView); + const { UNSAFE_getAllByType } = render(); + const scrollView = UNSAFE_getAllByType(ScrollView)[0]; // Simulate the pull gesture completing. scrollView.props.refreshControl.props.onRefresh(); @@ -153,14 +158,14 @@ describe('AC-W2, AC-W3, AC-W4 – RefreshControl props', () => { describe('AC-W5, AC-W6 – spinner while loading', () => { it('marks RefreshControl as refreshing while isLoading is true', () => { setup({ isLoading: true }); - const { UNSAFE_getByType } = render(); - expect(UNSAFE_getByType(ScrollView).props.refreshControl.props.refreshing).toBe(true); + const { UNSAFE_getAllByType } = render(); + expect(UNSAFE_getAllByType(ScrollView)[0].props.refreshControl.props.refreshing).toBe(true); }); it('clears refreshing once isLoading returns to false', () => { setup({ isLoading: false }); - const { UNSAFE_getByType } = render(); - expect(UNSAFE_getByType(ScrollView).props.refreshControl.props.refreshing).toBe(false); + const { UNSAFE_getAllByType } = render(); + expect(UNSAFE_getAllByType(ScrollView)[0].props.refreshControl.props.refreshing).toBe(false); }); }); @@ -177,8 +182,8 @@ describe('AC-W7 – no duplicate requests while already refreshing', () => { * a single additional call — and that the store guard is in place. */ const store = setup({ isLoading: true }); - const { UNSAFE_getByType } = render(); - const rc = UNSAFE_getByType(ScrollView).props.refreshControl; + const { UNSAFE_getAllByType } = render(); + const rc = UNSAFE_getAllByType(ScrollView)[0].props.refreshControl; rc.props.onRefresh(); @@ -203,11 +208,12 @@ describe('AC-W8 – error state', () => { it('resets refreshing (isLoading=false) after a failed refresh', () => { setup({ isLoading: false, error: 'Horizon down' }); - const { UNSAFE_getByType } = render(); - expect(UNSAFE_getByType(ScrollView).props.refreshControl.props.refreshing).toBe(false); + const { UNSAFE_getAllByType } = render(); + expect(UNSAFE_getAllByType(ScrollView)[0].props.refreshControl.props.refreshing).toBe(false); }); }); + // ───────────────────────────────────────────────────────────────────────────── // AC-W9 – Balance value is rendered // ───────────────────────────────────────────────────────────────────────────── diff --git a/__tests__/paymentSuccess.test.tsx b/__tests__/paymentSuccess.test.tsx index 222279d..1737c68 100644 --- a/__tests__/paymentSuccess.test.tsx +++ b/__tests__/paymentSuccess.test.tsx @@ -174,9 +174,10 @@ describe('graceful handling of missing or invalid data', () => { amount: undefined, destination: DESTINATION, } as any); - const { getByText, queryByText } = render(); - expect(getByText('—')).toBeTruthy(); + const { getAllByText, queryByText } = render(); + expect(getAllByText('—').length).toBeGreaterThan(0); expect(queryByText('— XLM')).toBeNull(); + }); it('handles missing date gracefully by showing fallback dash', () => { diff --git a/__tests__/send.test.tsx b/__tests__/send.test.tsx index d028af6..202bd5f 100644 --- a/__tests__/send.test.tsx +++ b/__tests__/send.test.tsx @@ -208,129 +208,47 @@ describe('AC3 – submit is blocked when the form is invalid', () => { // AC4 – Valid form calls sendXlmTransaction // ──────────────────────────────────────────────────────────────────────── -describe('AC4 – valid form calls sendXlmTransaction', () => { - it('calls sendXlmTransaction with correct arguments on a valid submission', async () => { +describe('AC4 – valid form calls router.push to review-transaction', () => { + it('navigates to review-transaction with correct arguments on a valid submission', async () => { const { getByPlaceholderText, getByText } = render(); fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION); fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT); fireEvent.press(getByText('Send Payment')); - await waitFor(() => getByText('Sign & Send')); - fireEvent.press(getByText('Sign & Send')); await waitFor(() => { - expect(mockSendXlmTransaction).toHaveBeenCalledWith( - MOCK_SECRET, VALID_DESTINATION, VALID_AMOUNT, '', - ); + expect(mockPush).toHaveBeenCalledWith({ + pathname: '/review-transaction', + params: { + destination: VALID_DESTINATION, + amount: VALID_AMOUNT, + memo: '', + }, + }); }); }); - it('passes memo text to sendXlmTransaction when provided', async () => { + it('passes memo text to review-transaction params when provided', async () => { const { getByPlaceholderText, getByText } = render(); fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION); fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT); fireEvent.changeText(getByPlaceholderText('Payment reference'), 'invoice-42'); fireEvent.press(getByText('Send Payment')); - await waitFor(() => getByText('Sign & Send')); - fireEvent.press(getByText('Sign & Send')); - - await waitFor(() => { - expect(mockSendXlmTransaction).toHaveBeenCalledWith( - MOCK_SECRET, VALID_DESTINATION, VALID_AMOUNT, 'invoice-42', - ); - }); - }); - - it('navigates to the payment success receipt with the tx hash, amount, and destination', async () => { - const { getByPlaceholderText, getByText } = render(); - - fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION); - fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT); - fireEvent.press(getByText('Send Payment')); - await waitFor(() => getByText('Sign & Send')); - fireEvent.press(getByText('Sign & Send')); await waitFor(() => { - expect(mockReplace).toHaveBeenCalledWith({ - pathname: '/payment-success', + expect(mockPush).toHaveBeenCalledWith({ + pathname: '/review-transaction', params: { - hash: 'abc123', - amount: VALID_AMOUNT, destination: VALID_DESTINATION, - date: expect.any(String), + amount: VALID_AMOUNT, + memo: 'invoice-42', }, }); }); }); - - it('refreshes wallet data after a successful send', async () => { - const refreshWalletData = jest.fn(); - setupWalletStore({ refreshWalletData }); - const { getByPlaceholderText, getByText } = render(); - - fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION); - fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT); - fireEvent.press(getByText('Send Payment')); - await waitFor(() => getByText('Sign & Send')); - fireEvent.press(getByText('Sign & Send')); - - await waitFor(() => { - expect(refreshWalletData).toHaveBeenCalled(); - }); - }); }); -// ──────────────────────────────────────────────────────────────────────── -// AC5 – Failure displays an error -// ──────────────────────────────────────────────────────────────────────── - -describe('AC5 – failure displays error', () => { - it('shows recovery guidance when sendXlmTransaction throws a known error', async () => { - mockSendXlmTransaction.mockRejectedValueOnce(new Error('tx_bad_seq')); - const { getByPlaceholderText, getByText } = render(); - - fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION); - fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT); - fireEvent.press(getByText('Send Payment')); - await waitFor(() => getByText('Sign & Send')); - fireEvent.press(getByText('Sign & Send')); - - await waitFor(() => { - // User-friendly title from the recovery guidance, not raw error code - expect(getByText('Sequence Error')).toBeTruthy(); - }); - }); - - it('shows default guidance when the error has no message', async () => { - mockSendXlmTransaction.mockRejectedValueOnce({}); - const { getByPlaceholderText, getByText } = render(); - - fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION); - fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT); - fireEvent.press(getByText('Send Payment')); - await waitFor(() => getByText('Sign & Send')); - fireEvent.press(getByText('Sign & Send')); - - await waitFor(() => { - expect(getByText('Transaction Failed')).toBeTruthy(); - }); - }); - - it('does NOT call router.back after a failed send', async () => { - mockSendXlmTransaction.mockRejectedValueOnce(new Error('network error')); - const { getByPlaceholderText, getByText } = render(); - - fireEvent.changeText(getByPlaceholderText('G...'), VALID_DESTINATION); - fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT); - fireEvent.press(getByText('Send Payment')); - await waitFor(() => getByText('Sign & Send')); - fireEvent.press(getByText('Sign & Send')); - - await waitFor(() => expect(getByText('Network Error')).toBeTruthy()); - expect(mockBack).not.toHaveBeenCalled(); - }); -}); // ──────────────────────────────────────────────────────────────────────── // AC6 – Scan option exists @@ -418,17 +336,21 @@ describe('AC8 – valid scan fills destination field', () => { fireEvent.changeText(getByPlaceholderText('G...'), SCANNED_ADDRESS); fireEvent.changeText(getByPlaceholderText('0.00'), VALID_AMOUNT); fireEvent.press(getByText('Send Payment')); - await waitFor(() => getByText('Sign & Send')); - fireEvent.press(getByText('Sign & Send')); await waitFor(() => { - expect(mockSendXlmTransaction).toHaveBeenCalledWith( - MOCK_SECRET, SCANNED_ADDRESS, VALID_AMOUNT, '', - ); + expect(mockPush).toHaveBeenCalledWith({ + pathname: '/review-transaction', + params: { + destination: SCANNED_ADDRESS, + amount: VALID_AMOUNT, + memo: '', + }, + }); }); }); }); + // ──────────────────────────────────────────────────────────────────────── // AC9 – Invalid QR shows an error // ──────────────────────────────────────────────────────────────────────── diff --git a/__tests__/vault.test.tsx b/__tests__/vault.test.tsx index 3b69696..0de1568 100644 --- a/__tests__/vault.test.tsx +++ b/__tests__/vault.test.tsx @@ -70,14 +70,14 @@ function setupStores(overrides: Record = {}) { mockDeposit.mockResolvedValue('tx_hash_1234567890abcdef'); mockWithdraw.mockResolvedValue('tx_hash_abcdef1234567890'); - mockUseWalletStore.mockReturnValue({ + const walletState = { publicKey: 'GPUBLIC123', balance: '100.0000000', getSecretKey: jest.fn(async () => 'SVALIDSECRET'), ...overrides, - } as any); + }; - mockUseVaultStore.mockReturnValue({ + const vaultState = { balance: '50.0000000', locks: [], isConfigured: true, @@ -94,9 +94,18 @@ function setupStores(overrides: Record = {}) { withdraw: mockWithdraw, withdrawMaturedLock: jest.fn(), ...overrides, - } as any); + }; + + mockUseWalletStore.mockImplementation((selector?: any) => + typeof selector === 'function' ? selector(walletState) : walletState + ); + + mockUseVaultStore.mockImplementation((selector?: any) => + typeof selector === 'function' ? selector(vaultState) : vaultState + ); } + const alertSpy = jest.spyOn(Alert, 'alert').mockImplementation(() => undefined); // ─── Lifecycle ──────────────────────────────────────────────────────────── @@ -416,3 +425,38 @@ describe('validation prevents action', () => { expect(queryByText('Confirm Deposit')).toBeNull(); }); }); + +describe('Vault Unavailable State (Issue #309)', () => { + it('renders VaultUnavailableState card when publicKey is null', async () => { + setupStores({ publicKey: null }); + const { getByText, queryByText } = render(); + + expect(getByText('Vault Unavailable')).toBeTruthy(); + expect(getByText('Create or import a wallet to use the Soroban Savings Vault.')).toBeTruthy(); + expect(queryByText('Set Aside for 30 Days')).toBeNull(); + }); + + it('renders VaultUnavailableState card when EXPO_PUBLIC_VAULT_ENABLED is false', async () => { + const originalEnv = process.env.EXPO_PUBLIC_VAULT_ENABLED; + process.env.EXPO_PUBLIC_VAULT_ENABLED = 'false'; + + try { + const { getByText, queryByText } = render(); + + expect(getByText('Vault Unavailable')).toBeTruthy(); + expect(getByText('The vault is currently disabled by configuration. This may be temporary while the backend is being updated.')).toBeTruthy(); + expect(queryByText('Set Aside for 30 Days')).toBeNull(); + } finally { + process.env.EXPO_PUBLIC_VAULT_ENABLED = originalEnv; + } + }); + + it('does not call loadBalance or loadLocks when vault is unavailable', () => { + setupStores({ publicKey: null }); + render(); + + expect(mockLoadBalance).not.toHaveBeenCalled(); + expect(mockLoadLocks).not.toHaveBeenCalled(); + }); +}); + diff --git a/app/(tabs)/vault.tsx b/app/(tabs)/vault.tsx index 0df3d10..4af115d 100644 --- a/app/(tabs)/vault.tsx +++ b/app/(tabs)/vault.tsx @@ -5,17 +5,19 @@ import { VaultLockList } from '../../src/components/VaultLockList'; import { VaultConfirmModal } from '../../src/components/VaultConfirmModal'; import { VaultIntroModal } from '../../src/components/VaultIntroModal'; import { VaultLockEducationModal } from '../../src/components/VaultLockEducationModal'; +import { VaultUnavailableState } from '../../src/components/VaultUnavailableState'; import { Input } from '../../src/components/Input'; import { AsyncActionButton } from '../../src/components/AsyncActionButton'; import { SIZES, RADIUS, ThemeColors } from '../../src/constants/theme'; import { useTheme } from '../../src/hooks/useTheme'; import { useVault } from '../../src/hooks/useVault'; +import { useVaultAvailability } from '../../src/hooks/useVaultAvailability'; import { useVaultDepositForm } from '../../src/features/vault/useVaultDepositForm'; import { useWalletStore } from '../../src/store/walletStore'; import { formatTimeRemaining } from '../../src/utils/lockTime'; import { validateAmount } from '../../src/utils/validation'; import { WALLET_SECRET_ACCESS_MESSAGE } from '../../src/utils/walletStorageErrors'; -import { PiggyBank, Info, Lock, HelpCircle, ShieldCheck, AlertTriangle, XCircle } from 'lucide-react-native'; +import { PiggyBank, Info, Lock, HelpCircle, ShieldCheck, AlertTriangle } from 'lucide-react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; const LOCK_PERIOD_SECONDS = 30 * 24 * 60 * 60; // 30 days @@ -28,6 +30,7 @@ export default function VaultScreen() { // Wallet & Vault stores const { publicKey, getSecretKey, balance: walletBalance } = useWalletStore(); + const { isAvailable, reasons, isContractConfigured } = useVaultAvailability(); const { balance, locks, @@ -55,11 +58,6 @@ export default function VaultScreen() { const [pendingAction, setPendingAction] = useState<'deposit' | 'withdraw' | 'lock' | null>(null); const [pendingUnlockDate, setPendingUnlockDate] = useState(''); - // Local helpers - const isVaultUnavailable = !publicKey; - const isMissingContractId = isVaultUnavailable && !isConfigured; - const isMissingRpcUrl = false; // For now, default to testnet RPC - // Initial setup useEffect(() => { const checkIntro = async () => { @@ -72,11 +70,12 @@ export default function VaultScreen() { }, []); useEffect(() => { - if (publicKey) { + if (isAvailable && publicKey) { loadBalance(publicKey); loadLocks(); } - }, [publicKey, loadBalance, loadLocks]); + }, [isAvailable, publicKey, loadBalance, loadLocks]); + // Handlers const dismissIntro = async () => { @@ -231,7 +230,7 @@ export default function VaultScreen() { /> - {isConfigured ? ( + {isContractConfigured ? ( @@ -251,38 +250,20 @@ export default function VaultScreen() { )} - {isVaultUnavailable ? ( - - - Vault Unavailable - - The Soroban Savings Vault cannot be used right now because the required configuration is - missing. - - {isMissingContractId && ( - - Missing configuration: - EXPO_PUBLIC_VAULT_CONTRACT_ID - - Set this in your .env file to the deployed Soroban contract ID. - - - )} - {isMissingRpcUrl && ( - - Missing configuration: - EXPO_PUBLIC_SOROBAN_RPC_URL - - Set this in your .env file to a Soroban RPC endpoint. - - - )} - - See docs/vault-ui-guidance.md for more information. - - + {!isAvailable ? ( + router.push('/(tabs)/settings')} + onRetry={() => { + if (publicKey) { + loadBalance(publicKey); + loadLocks(); + } + }} + /> ) : ( + createStyles(colors), [colors]); + const { isAvailable, reasons } = useVaultAvailability(); // Mock data for the lock const mockLock = { @@ -23,7 +26,20 @@ export default function VaultLockDetailScreen() { const isEligibleForWithdrawal = new Date(mockLock.unlockDate).getTime() <= Date.now(); + if (!isAvailable) { + return ( + + + router.push('/(tabs)/settings')} + /> + + ); + } + return ( + <> diff --git a/app/vault/[id].tsx b/app/vault/[id].tsx index 8debebd..1cbd841 100644 --- a/app/vault/[id].tsx +++ b/app/vault/[id].tsx @@ -1,21 +1,38 @@ import React, { useMemo } from 'react'; import { View, Text, StyleSheet, ScrollView, ActivityIndicator, Alert } from 'react-native'; -import { Stack, useLocalSearchParams } from 'expo-router'; +import { Stack, useLocalSearchParams, useRouter } from 'expo-router'; import { useTheme } from '../../src/hooks/useTheme'; import { useVault } from '../../src/hooks/useVault'; +import { useVaultAvailability } from '../../src/hooks/useVaultAvailability'; +import { VaultUnavailableState } from '../../src/components/VaultUnavailableState'; import { SIZES, RADIUS, ThemeColors } from '../../src/constants/theme'; import { Lock } from '../../src/store/vaultStore'; import { VaultLockDetail } from '../../src/components/VaultLockDetail'; export default function VaultLockDetailScreen() { const { id } = useLocalSearchParams(); + const router = useRouter(); const { colors } = useTheme(); const styles = useMemo(() => createStyles(colors), [colors]); + const { isAvailable, reasons } = useVaultAvailability(); const { locks, isLoadingLocks, findLock } = useVault(); + if (!isAvailable) { + return ( + + + router.push('/(tabs)/settings')} + /> + + ); + } + const lock: Lock | undefined = findLock(typeof id === 'string' ? id : ''); const isLoading = isLoadingLocks; + if (isLoading) { return ( diff --git a/docs/ui-states.md b/docs/ui-states.md index af1fc83..d3399a6 100644 --- a/docs/ui-states.md +++ b/docs/ui-states.md @@ -94,6 +94,8 @@ State conventions used throughout the app: | **Success** | Vault balance, TESTNET badge, and the locks list (each with locked amount, unlock date, status, and eligible actions for matured locks) render correctly. | | **Disabled** | "Lock Funds (30 days)" and withdraw actions must clearly indicate **mock mode** when no real contract is configured (`EXPO_PUBLIC_VAULT_CONTRACT_ID` unset) — this isn't a literal disabled button, but the UI must not imply a live/production action is being taken. Matured vs. immature locks must be visually distinct so users can tell which locks are eligible for withdrawal. | | **Pending** | Vault deposit, withdraw, and lock actions should show a clear in-flight state while the transaction is being prepared or confirmed, especially in mock mode where the outcome is simulated. | +| **Unavailable** | When no wallet is connected, the vault feature is disabled via configuration (`EXPO_PUBLIC_VAULT_ENABLED=false`), or the SDK reports the vault backend as not ready, the interactive form is replaced by a `VaultUnavailableState` card showing why the vault cannot be used and offering fallback navigation (to settings or retry). See `src/utils/vaultAvailability.ts` for evaluation logic. | + **Testnet & custody language:** every vault state must follow [Vault UI Guidance](./vault-ui-guidance.md) — no "savings account"/"bank"/"insured"/"secured" language, and mock mode must be visually distinguished from real contract mode at all times, per that doc's Summary Checklist. diff --git a/docs/vault-integration-assumptions.md b/docs/vault-integration-assumptions.md index 6f95b62..f5a5485 100644 --- a/docs/vault-integration-assumptions.md +++ b/docs/vault-integration-assumptions.md @@ -89,7 +89,27 @@ Below are the identified gaps and risks that require coordination across reposit --- +## 5. Vault Availability & Feature Flags + +The mobile client evaluates vault availability via `src/utils/vaultAvailability.ts`. This centralised check replaces ad-hoc inline checks across vault screens. + +### Feature Flag: `EXPO_PUBLIC_VAULT_ENABLED` + +An optional environment variable that can disable the vault UI entirely: + +- **Default:** `'true'` (vault is enabled) +- **Set to `'false'` or `'0'`:** Vault form is hidden and replaced with an unavailable state card +- **Use case:** Temporarily disable the vault during SDK upgrades, contract redeployment, or when the backend team signals the vault is not ready + +### SDK Readiness (future) + +When the PocketPay SDK ships a vault readiness signal, it should be wired into `evaluateVaultAvailability()` as a new input. See `docs/vault-sdk-capability-assumptions.md` for the expected interface. + +--- + ## Related Documentation - [Vault UI Guidance](./vault-ui-guidance.md) - Wording, Testnet constraints, and balance limitation details - [Vault Integration Risks](./vault-integration-risks.md) - Deep dive into risk analysis and contract simulation mechanics +- [Vault SDK Capability Assumptions](./vault-sdk-capability-assumptions.md) - SDK readiness signals & feature flag assumptions + diff --git a/docs/vault-sdk-capability-assumptions.md b/docs/vault-sdk-capability-assumptions.md new file mode 100644 index 0000000..bb1029b --- /dev/null +++ b/docs/vault-sdk-capability-assumptions.md @@ -0,0 +1,48 @@ +# Vault SDK Capability Assumptions + +This document describes the assumptions the vault unavailable state makes about PocketPay SDK and Soroban contract readiness. + +## Current Capability Checks + +| Check | Source | Fallback | +|----------------------|--------------------------------|-----------------------------------| +| Wallet loaded | `walletStore.publicKey` | Show "no wallet" unavailable | +| Feature flag | `EXPO_PUBLIC_VAULT_ENABLED` | Default `true` (vault enabled) | +| Contract configured | `isVaultConfigured()` | Mock mode (not unavailable) | + +## Future SDK Capability Signal + +When the PocketPay SDK (`pocketpay-sdk`) ships a vault readiness API, the mobile client should: + +1. Call `sdk.vault.isReady()` (or equivalent) during app initialization. +2. Store the result in `vaultStore` or a dedicated capability store. +3. Pass it to `evaluateVaultAvailability()` as a new input. +4. Add `'sdk-not-ready'` to the reasons array when the SDK reports the vault backend is not available. + +### Expected SDK Interface + +```typescript +interface PocketPaySDK { + vault: { + /** Returns true when the vault contract is deployed and reachable. */ + isReady(): Promise; + /** Returns the set of vault features currently available. */ + getCapabilities(): Promise; + }; +} + +interface VaultCapabilities { + deposit: boolean; + withdraw: boolean; + lock: boolean; + unlock: boolean; +} +``` + +Until this interface is available, the mobile client uses `isVaultConfigured()` (env-var check) as a proxy. + +## Related Documentation + +- [Vault Integration Assumptions](./vault-integration-assumptions.md) +- [Vault Integration Risks](./vault-integration-risks.md) +- [Vault UI Guidance](./vault-ui-guidance.md) diff --git a/docs/vault-ui-guidance.md b/docs/vault-ui-guidance.md index f699783..dcf706c 100644 --- a/docs/vault-ui-guidance.md +++ b/docs/vault-ui-guidance.md @@ -112,6 +112,33 @@ Any new vault UI that displays contract interaction details (transaction hashes, --- +## Vault Unavailable State + +When the vault cannot be used (no wallet, feature disabled, or SDK not ready), the UI renders a dedicated `VaultUnavailableState` component instead of the interactive form. + +### Triggering Conditions + +| Condition | Reason Code | User-Facing Message | +|---|---|---| +| No wallet loaded (`publicKey` null) | `no-wallet` | Create or import a wallet to use the Soroban Savings Vault. | +| `EXPO_PUBLIC_VAULT_ENABLED=false` | `feature-disabled` | The vault is currently disabled by configuration. | +| SDK reports vault not ready | `sdk-not-ready` | The vault backend is not yet available. | + +### UI Behaviour + +- The deposit/withdraw/lock form is **replaced** by `VaultUnavailableState` +- All vault action buttons are not rendered (preventing any interaction) +- A fallback navigation button is shown: + - `no-wallet` → "Go to Settings" (navigates to settings tab) + - `feature-disabled` / `sdk-not-ready` → "Try Again" (retriggers availability check) +- The docs link at the bottom points to this document + +### Important + +Mock mode (`EXPO_PUBLIC_VAULT_CONTRACT_ID` unset) is **not** treated as "unavailable". The vault form remains accessible with a warning banner explaining that no real funds are moved. This is intentional — mock mode exists for development and demo purposes. + +--- + ## Summary Checklist | Guideline | Status | @@ -124,6 +151,10 @@ Any new vault UI that displays contract interaction details (transaction hashes, | Multiple locks supported with distinct UI | Required | | Matured/immature locks visually distinct | Required | | Empty and loading states handled | Required | +| Vault unavailable state shown when capability is missing | Required | +| SDK capability assumptions documented | Required | +| Fallback navigation available from unavailable state | Required | +| Actions disabled when vault is unavailable | Required | | Contract docs referenced in UI footnotes or tooltips | Recommended | | Mock mode distinguished from real contract mode | Required | @@ -134,4 +165,6 @@ Any new vault UI that displays contract interaction details (transaction hashes, - [Security Guide](./security.md) — key handling, Testnet risks, and safe development practices - [Storage Guide](./storage.md) — how SecureStore and AsyncStorage are used - [Vault Integration Assumptions](./vault-integration-assumptions.md) — expected SDK/contract dependencies, placeholder behaviors, and known gaps +- [Vault SDK Capability Assumptions](./vault-sdk-capability-assumptions.md) — SDK readiness signals & feature flag assumptions - [Soroban Savings Vault contract](https://github.com/Axionvera/pocketpay-contracts) — contract source and interface + diff --git a/metro.config.js b/metro.config.js index 07778a0..fb5a509 100644 --- a/metro.config.js +++ b/metro.config.js @@ -34,7 +34,7 @@ config.resolver.resolveRequest = (context, moduleName, platform) => { // Babel-compiled Node build instead, which is already down-leveled. if (moduleName === '@stellar/stellar-sdk') { return { - filePath: path.resolve(__dirname, 'node_modules/@stellar/stellar-sdk/lib/index.js'), + filePath: path.resolve(__dirname, 'node_modules/@stellar/stellar-sdk/lib/cjs/index.js'), type: 'sourceFile', }; } diff --git a/package.json b/package.json index 20f2a4a..496d312 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@testing-library/jest-native/extend-expect" ], "moduleNameMapper": { + "^@/(.*)$": "/src/$1", "^expo-router$": "/__mocks__/expo-router.ts", "^expo-secure-store$": "/__mocks__/expo-secure-store.ts", "^pocketpay-sdk$": "/__mocks__/pocketpay-sdk.ts", diff --git a/src/components/VaultUnavailableState.tsx b/src/components/VaultUnavailableState.tsx new file mode 100644 index 0000000..57c5bdd --- /dev/null +++ b/src/components/VaultUnavailableState.tsx @@ -0,0 +1,146 @@ +import React, { useMemo } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { SIZES, RADIUS, ThemeColors } from '../constants/theme'; +import { useTheme } from '../hooks/useTheme'; +import { XCircle } from 'lucide-react-native'; +import { + VaultUnavailableReason, + describeUnavailableReason, +} from '../utils/vaultAvailability'; +import { Button } from './Button'; + +export interface VaultUnavailableStateProps { + reasons: VaultUnavailableReason[]; + onNavigateToSettings?: () => void; + onRetry?: () => void; +} + +export const VaultUnavailableState: React.FC = ({ + reasons, + onNavigateToSettings, + onRetry, +}) => { + const { colors } = useTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + + const primaryReason = reasons[0] ?? 'sdk-not-ready'; + const primaryCopy = describeUnavailableReason(primaryReason); + + const showSettingsButton = reasons.includes('no-wallet') && onNavigateToSettings; + const showRetryButton = + (reasons.includes('feature-disabled') || reasons.includes('sdk-not-ready')) && onRetry; + + return ( + + + Vault Unavailable + + The Soroban Savings Vault cannot be used right now because the required configuration or wallet is missing. + + + {reasons.map((reason) => { + const copy = describeUnavailableReason(reason); + return ( + + {copy.title} + {copy.message} + {copy.hint ? {copy.hint} : null} + + ); + })} + + + {showSettingsButton ? ( +