diff --git a/app/(protected)/(tabs)/settings/account.tsx b/app/(protected)/(tabs)/settings/account.tsx
index be0a5b6d9..dc9aa4b2a 100644
--- a/app/(protected)/(tabs)/settings/account.tsx
+++ b/app/(protected)/(tabs)/settings/account.tsx
@@ -1,7 +1,7 @@
import { useState } from 'react';
-import { ActivityIndicator, Alert, Modal, Pressable, Text, View } from 'react-native';
+import { Alert, Pressable, Text, View } from 'react-native';
import { Image } from 'expo-image';
-import { ChevronRight, X } from 'lucide-react-native';
+import { ChevronRight } from 'lucide-react-native';
import { Address } from 'viem';
import WalletIcon from '@/assets/images/wallet';
@@ -9,6 +9,7 @@ import CopyToClipboard from '@/components/CopyToClipboard';
import Navbar from '@/components/Navbar';
import PageLayout from '@/components/PageLayout';
import { SettingsCard } from '@/components/Settings';
+import DeleteAccountModal from '@/components/Settings/DeleteAccountModal';
import { BackButton } from '@/components/ui/back-button';
import { useDimension } from '@/hooks/useDimension';
import useUser from '@/hooks/useUser';
@@ -62,59 +63,12 @@ export default function Account() {
);
const deleteModal = (
- !isDeleting && setShowDeleteModal(false)}
- >
-
-
-
- Delete Account
- !isDeleting && setShowDeleteModal(false)}>
-
-
-
-
-
- Are you sure you want to delete your account? This action cannot be undone and will:
-
-
-
- • Remove all your data
- • Cancel any active cards
- • Delete your transaction history
- • Remove access to your wallet
-
-
-
- setShowDeleteModal(false)}
- className="mr-2 flex-1 rounded-xl bg-gray-700 py-4"
- disabled={isDeleting}
- >
- Cancel
-
-
-
- {isDeleting ? (
-
- ) : (
- Delete Account
- )}
-
-
-
-
-
+ isDeleting={isDeleting}
+ onCancel={() => setShowDeleteModal(false)}
+ onConfirm={confirmDelete}
+ />
);
return (
diff --git a/components/Settings/DeleteAccountModal.tsx b/components/Settings/DeleteAccountModal.tsx
new file mode 100644
index 000000000..cabea860a
--- /dev/null
+++ b/components/Settings/DeleteAccountModal.tsx
@@ -0,0 +1,190 @@
+import { useEffect, useState } from 'react';
+import {
+ ActivityIndicator,
+ KeyboardAvoidingView,
+ Modal,
+ Platform,
+ Pressable,
+ Text,
+ View,
+} from 'react-native';
+import { AlertTriangle, X } from 'lucide-react-native';
+
+import Input from '@/components/ui/input';
+import { useTotalSavingsUSD } from '@/hooks/useTotalSavingsUSD';
+import { useWalletTokens } from '@/hooks/useWalletTokens';
+import { cn, formatBalanceUSD } from '@/lib/utils';
+
+/**
+ * What the user has to type before "Delete Account" does anything.
+ *
+ * Two taps used to be enough, and a user closed an account holding $28 that way
+ * and then could not sign back in to reach it. Typing a word is the one step
+ * that cannot happen by accident.
+ */
+export const DELETE_CONFIRMATION_WORD = 'DELETE';
+
+/** Whether what was typed confirms the deletion. Case and stray spaces don't count against it. */
+export const isDeleteConfirmation = (typed: string): boolean =>
+ typed.trim().toUpperCase() === DELETE_CONFIRMATION_WORD;
+
+/**
+ * Tells the user what they still hold before they delete the account.
+ *
+ * Deleting does not move any money: it stays in their wallet, and a closed
+ * account cannot sign in to reach it. Wallet and savings are the pots that
+ * matter for that and are read the same way the home screen reads them; the
+ * card is left out because its balance means different things per issuer, so
+ * the figure is "at least" rather than the headline.
+ *
+ * Only mounted while the modal is open, so the balance queries don't run for
+ * everyone who merely opens Account details.
+ */
+export function HeldBalanceWarning() {
+ const { totalUSDExcludingVaultTokens: walletUsd } = useWalletTokens();
+ const { data: savingsUsd } = useTotalSavingsUSD();
+
+ const heldUsd = (walletUsd || 0) + (savingsUsd || 0);
+ if (heldUsd < 0.01) return null;
+
+ return (
+
+
+
+
+ You still have at least {formatBalanceUSD(heldUsd)} in Solid
+
+
+ Deleting your account doesn't move this money, and you won't be able to sign
+ back in to reach it. Withdraw it or send it to another wallet first.
+
+
+
+ );
+}
+
+type DeleteAccountModalProps = {
+ visible: boolean;
+ isDeleting: boolean;
+ onCancel: () => void;
+ onConfirm: () => void;
+};
+
+/**
+ * The last step before an account is closed.
+ *
+ * Closing locks the user out — every sign-in is refused afterwards, and only
+ * support can reopen the account — so this asks for more than a tap: it shows
+ * what the user still holds, and the delete button stays disabled until they
+ * type {@link DELETE_CONFIRMATION_WORD}.
+ */
+export default function DeleteAccountModal({
+ visible,
+ isDeleting,
+ onCancel,
+ onConfirm,
+}: DeleteAccountModalProps) {
+ const [confirmationText, setConfirmationText] = useState('');
+ const isConfirmed = isDeleteConfirmation(confirmationText);
+
+ // Whatever was typed must not carry over to the next opening — including after a failed
+ // attempt, which closes the modal from the parent rather than through `cancel`.
+ useEffect(() => {
+ if (!visible) setConfirmationText('');
+ }, [visible]);
+
+ const cancel = () => {
+ if (isDeleting) return;
+ onCancel();
+ };
+
+ const confirm = () => {
+ if (!isConfirmed || isDeleting) return;
+ onConfirm();
+ };
+
+ return (
+
+
+
+
+
+ Delete Account
+
+
+
+
+
+ {visible && }
+
+
+ Are you sure you want to delete your account? This action cannot be undone and will:
+
+
+
+ • Remove all your data
+ • Cancel any active cards
+ • Delete your transaction history
+ • Remove access to your wallet
+
+
+
+ Type {DELETE_CONFIRMATION_WORD} to
+ confirm
+
+
+
+
+
+ Cancel
+
+
+
+ {isDeleting ? (
+
+ ) : (
+
+ Delete Account
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/components/Settings/__tests__/DeleteAccountModal.test.tsx b/components/Settings/__tests__/DeleteAccountModal.test.tsx
new file mode 100644
index 000000000..38b6babd1
--- /dev/null
+++ b/components/Settings/__tests__/DeleteAccountModal.test.tsx
@@ -0,0 +1,151 @@
+import React from 'react';
+
+import DeleteAccountModal, { isDeleteConfirmation } from '@/components/Settings/DeleteAccountModal';
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const { act, create } = require('react-test-renderer');
+
+let mockWalletUsd = 0;
+let mockSavingsUsd: number | undefined = 0;
+
+jest.mock('@/hooks/useWalletTokens', () => ({
+ useWalletTokens: () => ({ totalUSDExcludingVaultTokens: mockWalletUsd }),
+}));
+jest.mock('@/hooks/useTotalSavingsUSD', () => ({
+ useTotalSavingsUSD: () => ({ data: mockSavingsUsd }),
+}));
+jest.mock('@/lib/utils', () => ({
+ cn: (...inputs: unknown[]) => inputs.filter(Boolean).join(' '),
+ formatBalanceUSD: (value: number) => `$${value.toFixed(2)}`,
+}));
+jest.mock('@/components/ui/input', () => 'Input');
+jest.mock('lucide-react-native', () => ({ AlertTriangle: 'AlertTriangle', X: 'X' }));
+
+const render = (props: Partial> = {}) => {
+ const onConfirm = jest.fn();
+ const onCancel = jest.fn();
+ let tree: any;
+ act(() => {
+ tree = create(
+ ,
+ );
+ });
+ return { tree, onConfirm, onCancel };
+};
+
+/** The "Delete Account" button — the pressable that owns the destructive action. */
+const deleteButton = (tree: any) =>
+ tree.root.find(
+ (node: any) => node.props.accessibilityLabel === 'Delete Account' && node.props.onPress,
+ );
+
+const typeConfirmation = (tree: any, text: string) => {
+ act(() => {
+ tree.root.findByType('Input').props.onChangeText(text);
+ });
+};
+
+const allText = (tree: any): string =>
+ tree.root
+ .findAll((node: any) => typeof node.children?.[0] === 'string')
+ .map((node: any) => node.children.join(''))
+ .join(' ');
+
+afterEach(() => {
+ mockWalletUsd = 0;
+ mockSavingsUsd = 0;
+});
+
+/**
+ * A user closed an account holding $28 with two taps, then could not sign back
+ * in to reach it — a closed account refuses every sign-in. Deleting now takes a
+ * typed word, and says what the user still holds before they do it.
+ */
+describe('DeleteAccountModal', () => {
+ test('does not delete until the confirmation word is typed', () => {
+ const { tree, onConfirm } = render();
+
+ expect(deleteButton(tree).props.disabled).toBe(true);
+ act(() => deleteButton(tree).props.onPress());
+ expect(onConfirm).not.toHaveBeenCalled();
+
+ typeConfirmation(tree, 'DELET');
+ expect(deleteButton(tree).props.disabled).toBe(true);
+
+ act(() => tree.unmount());
+ });
+
+ test('deletes once the word is typed, whatever the case', () => {
+ const { tree, onConfirm } = render();
+
+ typeConfirmation(tree, ' delete ');
+ expect(deleteButton(tree).props.disabled).toBe(false);
+
+ act(() => deleteButton(tree).props.onPress());
+ expect(onConfirm).toHaveBeenCalledTimes(1);
+
+ act(() => tree.unmount());
+ });
+
+ test('warns what the user still holds before they delete', () => {
+ mockWalletUsd = 28.01;
+ mockSavingsUsd = 0.04;
+ const { tree } = render();
+
+ expect(allText(tree)).toContain('You still have at least $28.05 in Solid');
+
+ act(() => tree.unmount());
+ });
+
+ test('says nothing about money to a user who holds none', () => {
+ mockSavingsUsd = undefined;
+ const { tree } = render();
+
+ expect(allText(tree)).not.toContain('You still have');
+
+ act(() => tree.unmount());
+ });
+
+ test('forgets what was typed once it closes', () => {
+ const { tree, onConfirm, onCancel } = render();
+ typeConfirmation(tree, 'DELETE');
+
+ act(() => {
+ tree.update(
+ ,
+ );
+ });
+ act(() => {
+ tree.update(
+ ,
+ );
+ });
+
+ expect(deleteButton(tree).props.disabled).toBe(true);
+
+ act(() => tree.unmount());
+ });
+});
+
+describe('isDeleteConfirmation', () => {
+ test.each([
+ ['DELETE', true],
+ ['delete', true],
+ [' Delete ', true],
+ ['DELETED', false],
+ ['', false],
+ ])('%p -> %p', (typed, expected) => {
+ expect(isDeleteConfirmation(typed)).toBe(expected);
+ });
+});