Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions __tests__/paymentSuccess.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* AC4 – Receipt provides navigation back to wallet or activity.
* AC5 – Explorer link is shown where available (and hidden when not).
* AC6 – Secret key information is never rendered on the receipt.
* AC7 – Copy is honest about confirmation, not a vague "submitted" claim (issue #253).
*/

import React from 'react';
Expand Down Expand Up @@ -89,6 +90,19 @@ describe('AC1-3 – receipt shows transaction details', () => {
});
});

// ─────────────────────────────────────────────────────────────────────────────
// AC7 – Honest confirmation copy (issue #253)
// ─────────────────────────────────────────────────────────────────────────────

describe('AC7 – honest confirmation copy', () => {
it('states the transaction was confirmed, not just vaguely "sent"', () => {
const { getByText, queryByText } = render(<PaymentSuccessScreen />);
expect(getByText('Payment Confirmed')).toBeTruthy();
expect(getByText('Your transaction was confirmed on the network.')).toBeTruthy();
expect(queryByText('Payment Sent')).toBeNull();
});
});

// ─────────────────────────────────────────────────────────────────────────────
// AC4 – Navigation back to wallet or activity
// ─────────────────────────────────────────────────────────────────────────────
Expand Down
63 changes: 63 additions & 0 deletions __tests__/signerStore.confirming.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useSignerStore } from '../src/store/signerStore';

describe('signer store confirming phase', () => {
beforeEach(() => {
useSignerStore.getState().reset();
});

it('enters confirming between submitting and completed', () => {
const store = useSignerStore.getState();

store.startReview({
requestId: 'req-1',
sourcePublicKey: 'GSOURCE',
destinationPublicKey: 'GDEST',
amount: '10',
assetCode: 'XLM',
network: 'Testnet',
createdAt: new Date().toISOString(),
timeoutSeconds: 30,
});

store.enterHandoff();
store.enterSigning();
store.enterSubmitting();
expect(useSignerStore.getState().phase).toBe('submitting');

store.enterConfirming();
expect(useSignerStore.getState().phase).toBe('confirming');

store.completeSigning({
hash: 'abc123',
review: useSignerStore.getState().currentReview!,
signerType: 'local',
completedAt: new Date().toISOString(),
});

expect(useSignerStore.getState().phase).toBe('completed');
});

it('keeps confirming separate from failed when submission never resolves cleanly', () => {
const store = useSignerStore.getState();

store.startReview({
requestId: 'req-2',
sourcePublicKey: 'GSOURCE',
destinationPublicKey: 'GDEST',
amount: '10',
assetCode: 'XLM',
network: 'Testnet',
createdAt: new Date().toISOString(),
timeoutSeconds: 30,
});

store.enterHandoff();
store.enterSigning();
store.enterSubmitting();
store.enterConfirming();

store.failSigning({ type: 'network_error', message: 'Could not confirm submission.' });

expect(useSignerStore.getState().phase).toBe('failed');
});
});
159 changes: 159 additions & 0 deletions __tests__/walletStore.pendingTransactions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* walletStore – Optimistic Pending Transactions (issue #253)
*
* AC-P1 – addPendingTransaction inserts an entry keyed by hash, visible in transactions.
* AC-P2 – concurrent pending entries with different hashes don't clobber each other.
* AC-P3 – refreshWalletData reconciles: a pending hash that comes back from Horizon
* is dropped from the optimistic map so it isn't shown twice.
* AC-P4 – an unreconciled pending entry stays visible as pending (soft, no expiry).
*/

import { act } from 'react-test-renderer';

jest.mock('../src/services/stellar');

jest.mock('@stellar/stellar-sdk', () => ({
Keypair: { fromSecret: jest.fn(() => ({ publicKey: () => 'GPUBLIC123' })) },
Horizon: { Server: jest.fn() },
TransactionBuilder: jest.fn(),
Operation: { payment: jest.fn() },
Asset: { native: jest.fn() },
Memo: { text: jest.fn() },
Networks: { TESTNET: 'Test SDF Network ; September 2015' },
}));

jest.mock('@react-native-async-storage/async-storage', () => ({
getItem: jest.fn(async () => null),
setItem: jest.fn(async () => {}),
removeItem: jest.fn(async () => {}),
}));

import { fetchTransactionsPage, fetchXlmBalance } from '../src/services/stellar';
import { useWalletStore } from '../src/store/walletStore';

const mockFetchTransactionsPage = fetchTransactionsPage as jest.MockedFunction<
typeof fetchTransactionsPage
>;
const mockFetchXlmBalance = fetchXlmBalance as jest.MockedFunction<typeof fetchXlmBalance>;

beforeEach(() => {
jest.clearAllMocks();
useWalletStore.setState({
publicKey: 'GPUBLIC123',
balance: '0.0000000',
transactions: [],
pendingTransactions: {},
isLoading: false,
nextCursor: null,
hasMoreTransactions: false,
error: null,
});
mockFetchXlmBalance.mockResolvedValue('100.0000000');
});

describe('AC-P1 – addPendingTransaction', () => {
it('inserts a pending entry keyed by hash and shows it in the transaction list', () => {
useWalletStore.getState().addPendingTransaction('hash1', { id: 'hash1', amount: '10.0000000' });

const state = useWalletStore.getState();
expect(state.pendingTransactions['hash1'].status).toBe('pending');
expect(state.transactions[0].id).toBe('hash1');
expect(state.transactions[0].status).toBe('pending');
});
});

describe('AC-P2 – concurrent pending entries', () => {
it('does not clobber a concurrent pending entry with a different hash', () => {
useWalletStore.getState().addPendingTransaction('hash1', { id: 'hash1', amount: '10' });
useWalletStore.getState().addPendingTransaction('hash2', { id: 'hash2', amount: '20' });

const state = useWalletStore.getState();
expect(Object.keys(state.pendingTransactions).sort()).toEqual(['hash1', 'hash2']);
expect(state.transactions.map((t) => t.id).sort()).toEqual(['hash1', 'hash2']);
});
});

describe('AC-P3 – reconcile on refresh', () => {
it('drops the optimistic entry once refresh brings back the same hash (no duplicate)', async () => {
useWalletStore.getState().addPendingTransaction('hash1', { id: 'hash1', amount: '10' });

mockFetchTransactionsPage.mockResolvedValueOnce({
records: [{ id: 'op1', transaction_hash: 'hash1', amount: '10.0000000' }] as any,
nextCursor: null,
hasMore: false,
});

await act(async () => {
await useWalletStore.getState().refreshWalletData();
});

const state = useWalletStore.getState();
expect(state.pendingTransactions['hash1']).toBeUndefined();
expect(
state.transactions.filter((t) => t.id === 'hash1' || t.transaction_hash === 'hash1')
).toHaveLength(1);
});

it('reconciles using the hash field on the submit response, not the Horizon operation id', async () => {
useWalletStore.getState().addPendingTransaction('hash1', { id: 'hash1', amount: '10' });

// Horizon's operation id differs from the transaction hash it belongs to.
mockFetchTransactionsPage.mockResolvedValueOnce({
records: [{ id: 'some-other-operation-id', transaction_hash: 'hash1' }] as any,
nextCursor: null,
hasMore: false,
});

await act(async () => {
await useWalletStore.getState().refreshWalletData();
});

expect(useWalletStore.getState().pendingTransactions['hash1']).toBeUndefined();
});
});

describe('AC-P2b – concurrent add during an in-flight refresh', () => {
it('does not drop a pending transaction added while a refresh is in flight', async () => {
useWalletStore.getState().addPendingTransaction('hash1', { id: 'hash1', amount: '10' });

let resolveFetch: (value: any) => void = () => {};
const fetchPromise = new Promise((resolve) => {
resolveFetch = resolve;
});
mockFetchTransactionsPage.mockReturnValueOnce(fetchPromise as any);

const refreshPromise = useWalletStore.getState().refreshWalletData();

// Simulate a second send completing while the first refresh is still in flight.
useWalletStore.getState().addPendingTransaction('hash2', { id: 'hash2', amount: '20' });

await act(async () => {
resolveFetch({ records: [], nextCursor: null, hasMore: false });
await refreshPromise;
});

const state = useWalletStore.getState();
expect(state.pendingTransactions['hash2']).toBeDefined();
expect(state.transactions.some((t) => t.id === 'hash2')).toBe(true);
});
});

describe('AC-P4 – unreconciled pending entries stay visible', () => {
it('keeps a pending entry when refresh does not bring back its hash yet', async () => {
useWalletStore.getState().addPendingTransaction('hash1', { id: 'hash1', amount: '10' });

mockFetchTransactionsPage.mockResolvedValueOnce({
records: [] as any,
nextCursor: null,
hasMore: false,
});

await act(async () => {
await useWalletStore.getState().refreshWalletData();
});

const state = useWalletStore.getState();
expect(state.pendingTransactions['hash1']).toBeDefined();
expect(state.transactions.some((t) => t.id === 'hash1' && t.status === 'pending')).toBe(true);
});
});
4 changes: 4 additions & 0 deletions app/(tabs)/history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,10 @@ export default function HistoryScreen() {
styles.listContent,
transactions.length === 0 && styles.listContentEmpty,
]}
// This manual refresh is what reconciles optimistic pending transactions
// against Horizon (see walletStore.refreshWalletData). A future polling
// hook could trigger it automatically on an interval, shaped like
// useOnlineStatus.ts (setInterval + AppState foreground listener).
refreshControl={
<RefreshControl
refreshing={isLoading}
Expand Down
46 changes: 2 additions & 44 deletions app/(tabs)/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
import { useAppLockStore } from '../../src/store/appLockStore';
import { ThemeMode } from '../../src/store/appStore';
import { WalletResetConfirmModal } from '../../src/components/WalletResetConfirmModal';
import { StatusBadge, BadgeTone } from '../../src/components/StatusBadge';
import { SecretKeyReveal } from '../../src/components/SecretKeyReveal';
import { useNetworkEnvironment, EnvironmentWarning } from '../../src/features/settings';

Expand Down Expand Up @@ -369,8 +370,6 @@ export default function SettingsScreen() {

// ─── Sub-components ──────────────────────────────────────────────────────

type BadgeTone = 'info' | 'success' | 'warning' | 'error';

interface EnvRowBadge {
text: string;
tone: BadgeTone;
Expand Down Expand Up @@ -412,44 +411,14 @@ function EnvRow({
>
{value}
</Text>
{badge ? <Badge styles={styles} colors={colors} badge={badge} /> : null}
{badge ? <StatusBadge text={badge.text} tone={badge.tone} /> : null}
</View>
</View>
</View>
</View>
);
}

function Badge({
styles,
colors,
badge,
}: {
styles: ReturnType<typeof createStyles>;
colors: ThemeColors;
badge: EnvRowBadge;
}) {
const bg = {
info: 'rgba(0, 229, 255, 0.12)',
success: 'rgba(0, 230, 118, 0.12)',
warning: 'rgba(255, 196, 0, 0.12)',
error: 'rgba(255, 61, 0, 0.12)',
}[badge.tone];

const fg = {
info: colors.primary,
success: colors.success,
warning: colors.warning,
error: colors.error,
}[badge.tone];

return (
<View style={[styles.badge, { backgroundColor: bg }]}>
<Text style={[styles.badgeText, { color: fg }]}>{badge.text}</Text>
</View>
);
}

function WarningBanner({
warning,
styles,
Expand Down Expand Up @@ -572,17 +541,6 @@ const createStyles = (colors: ThemeColors) =>
fontSize: 15,
fontWeight: '600',
},
badge: {
paddingHorizontal: SIZES.sm,
paddingVertical: 2,
borderRadius: RADIUS.round,
},
badgeText: {
fontSize: 10,
fontWeight: '700',
letterSpacing: 0.4,
textTransform: 'uppercase',
},
divider: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.border,
Expand Down
4 changes: 2 additions & 2 deletions app/payment-success.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ export default function PaymentSuccessScreen() {
<View style={styles.successIcon}>
<CheckCircle color={COLORS.success} size={72} />
</View>
<Text style={styles.title}>Payment Sent</Text>
<Text style={styles.subtitle}>Your transaction was submitted successfully.</Text>
<Text style={styles.title}>Payment Confirmed</Text>
<Text style={styles.subtitle}>Your transaction was confirmed on the network.</Text>

<View style={styles.card}>
<View style={styles.row}>
Expand Down
Loading