diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index ad1e942..d20b897 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -10,7 +10,10 @@ import {
GraduationCap,
Laptop,
Calendar,
- AlertCircle
+ AlertCircle,
+ Loader,
+ CheckCircle,
+ XCircle,
} from 'lucide-react-native';
import { colors } from '../../constants/colors';
import { EmptyState } from '../../components/shared/EmptyState';
@@ -70,6 +73,16 @@ export default function HomeScreen() {
void fetchDashboard();
};
+ const pendingTransactions = useLoansStore((s) => s.pendingTransactions);
+ const setPendingTransactions = useLoansStore((s) => s.setPendingTransactions);
+
+ // Sync pending txs from the persisted queue on mount
+ useEffect(() => {
+ import('../../src/transactions/pending-queue').then(({ pendingQueue }) => {
+ pendingQueue.getAll().then(setPendingTransactions);
+ });
+ }, [setPendingTransactions]);
+
const activeLoans = loans.filter((l) => l.status === 'active');
const nextInstallment = activeLoans
.flatMap((l) => l.installments.filter((i) => !i.paid).map((i) => ({ ...i, loanId: l.id })))
@@ -166,6 +179,67 @@ export default function HomeScreen() {
+ {/* Pending Transactions Banner */}
+ {pendingTransactions.filter((tx) => tx.status === 'pending').length > 0 && (
+
+
+
+
+
+
+ Transaction In Progress
+
+
+ {pendingTransactions.filter((tx) => tx.status === 'pending').length} pending transaction(s) — tracking on-chain
+
+
+
+ )}
+
+ {/* Transaction Result Banner */}
+ {pendingTransactions.filter((tx) => tx.status === 'confirmed' || tx.status === 'failed' || tx.status === 'expired').length > 0 && (
+
+
+ Recent Transactions
+
+ {pendingTransactions
+ .filter((tx) => tx.status !== 'pending')
+ .slice(0, 5)
+ .map((tx) => {
+ const isConfirmed = tx.status === 'confirmed';
+ const isFailed = tx.status === 'failed' || tx.status === 'expired';
+ const StatusIcon = isConfirmed ? CheckCircle : XCircle;
+ const statusColor = isConfirmed ? colors.success : colors.error;
+ return (
+
+
+
+ {tx.type === 'REPAYMENT' ? 'Repayment' : tx.type === 'LOAN_CREATION' ? 'Loan Creation' : tx.type} — ${tx.amount?.toLocaleString() ?? ''}
+
+
+ {isConfirmed ? 'Confirmed' : 'Failed'}
+
+
+ );
+ })}
+
+ )}
+
{/* Quick Actions Grid */}
{[
diff --git a/app/(tabs)/loans.tsx b/app/(tabs)/loans.tsx
index 9fbc682..9966ca7 100644
--- a/app/(tabs)/loans.tsx
+++ b/app/(tabs)/loans.tsx
@@ -7,7 +7,8 @@ import {
CheckCircle,
Clock,
XCircle,
- ChevronRight,
+ Loader,
+ RefreshCw,
} from 'lucide-react-native';
import { colors } from '../../constants/colors';
import { Card } from '../../components/shared/Card';
@@ -16,7 +17,9 @@ import { useLoansStore } from '../../stores/loans.store';
import { loansService } from '../../services/loans.service';
import { useTranslation } from '../../hooks/useTranslation';
import { formatDate } from '../../src/locales/i18n';
+import { pendingQueue } from '../../src/transactions/pending-queue';
import type { Loan, LoanStatus } from '../../types/loan.types';
+import type { PendingTransaction } from '../../types/transaction.types';
function getStatusConfig(status: LoanStatus, t: (key: string, opts?: any) => string): {
label: string;
@@ -40,6 +43,28 @@ function getStatusConfig(status: LoanStatus, t: (key: string, opts?: any) => str
}
}
+/** Small badge showing if a loan has a pending on-chain tx. */
+function PendingTxBadge({ loanId }: { loanId: string }) {
+ const pendingTransactions = useLoansStore((s) => s.pendingTransactions);
+ const loanPendingTxs = pendingTransactions.filter(
+ (tx) => tx.targetLoanId === loanId && tx.status === 'pending',
+ );
+
+ if (loanPendingTxs.length === 0) return null;
+
+ return (
+
+
+
+ {loanPendingTxs.length} pending
+
+
+ );
+}
+
interface LoanCardProps {
loan: Loan;
t: (key: string, opts?: any) => string;
@@ -77,18 +102,21 @@ function LoanCard({ loan, t }: LoanCardProps) {
- {/* Status badge */}
-
-
-
+
+
- {statusConfig.label}
-
+
+
+ {statusConfig.label}
+
+
@@ -149,27 +177,42 @@ export default function LoansScreen() {
const { t } = useTranslation();
const loans = useLoansStore((s) => s.loans);
const setLoans = useLoansStore((s) => s.setLoans);
+ const pendingTransactions = useLoansStore((s) => s.pendingTransactions);
+ const setPendingTransactions = useLoansStore((s) => s.setPendingTransactions);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState(null);
+ // Sync pending txs from the persisted queue on mount
+ const syncPendingTxs = useCallback(async () => {
+ const pendings = await pendingQueue.getAll();
+ setPendingTransactions(pendings);
+ }, [setPendingTransactions]);
+
const fetchLoans = useCallback(async () => {
setError(null);
try {
const data = await loansService.getMyLoans();
setLoans(data);
+ await syncPendingTxs();
} catch {
setError(t('loans.errorLoading'));
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
- }, [setLoans]);
+ }, [setLoans, syncPendingTxs]);
useEffect(() => {
void fetchLoans();
}, [fetchLoans]);
+ // Re-sync pending txs periodically and whenever local count changes
+ useEffect(() => {
+ const interval = setInterval(syncPendingTxs, 5000);
+ return () => clearInterval(interval);
+ }, [syncPendingTxs]);
+
const handleRefresh = () => {
setIsRefreshing(true);
void fetchLoans();
@@ -205,6 +248,12 @@ export default function LoansScreen() {
);
}
+ const pendingCount = pendingTransactions.filter((tx) => tx.status === 'pending').length;
+ const recentCompleted = pendingTransactions
+ .filter((tx) => tx.status !== 'pending')
+ .sort((a, b) => b.updatedAt - a.updatedAt)
+ .slice(0, 5);
+
return (
+ {/* Pending Transactions Banner */}
+ {pendingCount > 0 && (
+
+
+
+
+
+
+ Transactions In Progress
+
+
+ {pendingCount} pending transaction(s) — status being tracked on-chain
+
+
+
+ )}
+
+ {/* Recent Completed Transactions */}
+ {recentCompleted.length > 0 && (
+
+
+ Recent Transaction Activity
+
+ {recentCompleted.map((tx) => {
+ const isConfirmed = tx.status === 'confirmed';
+ const StatusIcon = isConfirmed ? CheckCircle : XCircle;
+ const statusColor = isConfirmed ? colors.success : colors.error;
+ return (
+
+
+
+ {tx.type === 'REPAYMENT' ? 'Repayment' : tx.type === 'LOAN_CREATION' ? 'Loan' : tx.type}
+ {tx.amount ? ` — $${tx.amount.toLocaleString()}` : ''}
+
+
+ {isConfirmed ? 'Confirmed' : tx.status === 'expired' ? 'Expired' : 'Failed'}
+
+
+ );
+ })}
+
+ )}
+
{/* Summary */}
{});
+ }
} else if (state === 'background' || state === 'inactive') {
lastActiveRef.current = Date.now();
clearIdleTimer();
@@ -147,6 +159,25 @@ function RootLayout() {
}
}, [isLocked, isAuthenticated, startIdleTimer]);
+ // ─── Transaction poller lifecycle ─────────────────────────────────────
+ useEffect(() => {
+ if (!isLoading && isAuthenticated) {
+ startTxPoller();
+ }
+ return () => {
+ stopTxPoller();
+ };
+ }, [isLoading, isAuthenticated]);
+
+ // ─── Pending‑tx sync on store hydration ───────────────────────────────
+ useEffect(() => {
+ if (!isLoading && isAuthenticated) {
+ pendingQueue.getAll().then((all) => {
+ useLoansStore.getState().setPendingTransactions(all);
+ });
+ }
+ }, [isLoading, isAuthenticated]);
+
const prevConnectedRef = useRef(true);
useEffect(() => {
diff --git a/hooks/useRepayment.ts b/hooks/useRepayment.ts
index 8bcd85d..7093fca 100644
--- a/hooks/useRepayment.ts
+++ b/hooks/useRepayment.ts
@@ -1,6 +1,8 @@
import { useCallback } from 'react';
import { loansService } from '../services/loans.service';
import { useTransaction } from './useTransaction';
+import { pendingQueue } from '../src/transactions/pending-queue';
+import { useLoansStore } from '../stores/loans.store';
import type { UseTransactionReturn } from './useTransaction';
export interface UseRepaymentReturn extends UseTransactionReturn {
@@ -20,7 +22,22 @@ export function useRepayment(): UseRepaymentReturn {
amount,
);
- await execute(unsignedXdr);
+ const result = await execute(unsignedXdr);
+
+ // On successful broadcast, persist the pending tx for background tracking
+ if (result?.txHash) {
+ const entry = await pendingQueue.add({
+ txHash: result.txHash,
+ type: 'REPAYMENT',
+ targetLoanId: loanId,
+ targetInstallmentIndex: installmentIndex,
+ amount,
+ });
+
+ // Sync into the loans store for live UI updates
+ const all = await pendingQueue.getAll();
+ useLoansStore.getState().setPendingTransactions(all);
+ }
},
[status, execute],
);
diff --git a/services/transactions.service.ts b/services/transactions.service.ts
index efd5e81..78237ac 100644
--- a/services/transactions.service.ts
+++ b/services/transactions.service.ts
@@ -1,10 +1,20 @@
import api from './api';
+import { addBreadcrumb, captureServiceError } from './sentry';
import type { TransactionResult } from '../types/transaction.types';
interface SubmitSignedXdrResponse {
txHash: string;
}
+/**
+ * On‑chain status returned by the API for a submitted transaction hash.
+ */
+export interface TxStatusResponse {
+ status: 'pending' | 'confirmed' | 'failed' | 'expired';
+ /** Human‑readable message from the chain / API. */
+ message?: string;
+}
+
export const transactionsService = {
async submitSignedXdr(signedXdr: string): Promise {
const res = await api.post('/transactions/submit', {
@@ -12,4 +22,21 @@ export const transactionsService = {
});
return { txHash: res.data.txHash, signedXdr };
},
+
+ /**
+ * Poll the API for the on‑chain resolution of a previously submitted tx.
+ * Returns the current status and an optional human‑readable message.
+ */
+ async getTxStatus(txHash: string): Promise {
+ addBreadcrumb('transactions.service', 'Polling tx status', { txHash });
+ try {
+ const res = await api.get(`/transactions/${txHash}/status`);
+ return res.data;
+ } catch (error) {
+ captureServiceError('transactions', 'getTxStatus', error);
+ // Network errors during polling should be handled gracefully —
+ // return 'pending' so the poller retries with backoff.
+ return { status: 'pending', message: 'Network error — will retry' };
+ }
+ },
};
diff --git a/src/transactions/__tests__/pending-queue.test.ts b/src/transactions/__tests__/pending-queue.test.ts
new file mode 100644
index 0000000..5a56692
--- /dev/null
+++ b/src/transactions/__tests__/pending-queue.test.ts
@@ -0,0 +1,206 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+
+jest.mock('@react-native-async-storage/async-storage', () => ({
+ getItem: jest.fn(),
+ setItem: jest.fn(),
+ removeItem: jest.fn(),
+}));
+
+const mockGetItem = AsyncStorage.getItem as jest.Mock;
+const mockSetItem = AsyncStorage.setItem as jest.Mock;
+const mockRemoveItem = AsyncStorage.removeItem as jest.Mock;
+
+const QUEUE_KEY = '@stepfi/pending-transactions';
+
+function createMockQueue(overrides: Record = {}) {
+ return {
+ id: 'test-id-1',
+ txHash: '0xabc123def456',
+ type: 'REPAYMENT',
+ targetLoanId: 'loan-1',
+ targetInstallmentIndex: 0,
+ amount: 100,
+ status: 'pending',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ retryCount: 0,
+ ...overrides,
+ };
+}
+
+describe('pendingQueue', () => {
+ let pendingQueue: typeof import('../pending-queue').pendingQueue;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetItem.mockReset();
+ mockSetItem.mockReset();
+ mockRemoveItem.mockReset();
+ });
+
+ beforeAll(async () => {
+ pendingQueue = (await import('../pending-queue')).pendingQueue;
+ });
+
+ describe('add', () => {
+ it('persists a new pending transaction to the queue', async () => {
+ mockGetItem.mockResolvedValue(JSON.stringify([]));
+
+ const entry = await pendingQueue.add({
+ txHash: '0xabc',
+ type: 'REPAYMENT',
+ targetLoanId: 'loan-1',
+ targetInstallmentIndex: 0,
+ amount: 100,
+ });
+
+ expect(entry.id).toBeDefined();
+ expect(entry.txHash).toBe('0xabc');
+ expect(entry.type).toBe('REPAYMENT');
+ expect(entry.targetLoanId).toBe('loan-1');
+ expect(entry.status).toBe('pending');
+ expect(entry.retryCount).toBe(0);
+ expect(mockSetItem).toHaveBeenCalledTimes(1);
+ expect(mockSetItem).toHaveBeenCalledWith(
+ QUEUE_KEY,
+ expect.any(String),
+ );
+ });
+
+ it('appends to existing queue entries', async () => {
+ const existing = [createMockQueue({ id: 'existing-1' })];
+ mockGetItem.mockResolvedValue(JSON.stringify(existing));
+
+ await pendingQueue.add({
+ txHash: '0xnew',
+ type: 'LOAN_CREATION',
+ });
+
+ const saved = JSON.parse(mockSetItem.mock.calls[0][1]);
+ expect(saved).toHaveLength(2);
+ expect(saved[0].id).toBe('existing-1');
+ expect(saved[1].txHash).toBe('0xnew');
+ });
+ });
+
+ describe('updateStatus', () => {
+ it('updates status, retryCount, and lastPolledAt for an existing entry', async () => {
+ const entry = createMockQueue({ id: 'tx-1' });
+ mockGetItem.mockResolvedValue(JSON.stringify([entry]));
+
+ const result = await pendingQueue.updateStatus('tx-1', 'confirmed', undefined, 5000);
+
+ expect(result).toBe(true);
+ const saved = JSON.parse(mockSetItem.mock.calls[0][1]);
+ expect(saved[0].status).toBe('confirmed');
+ expect(saved[0].lastPolledAt).toBe(5000);
+ expect(saved[0].updatedAt).toBeGreaterThan(entry.updatedAt);
+ });
+
+ it('returns false if the entry does not exist', async () => {
+ mockGetItem.mockResolvedValue(JSON.stringify([]));
+ const result = await pendingQueue.updateStatus('nonexistent', 'confirmed');
+ expect(result).toBe(false);
+ });
+
+ it('increments retryCount when provided', async () => {
+ const entry = createMockQueue({ id: 'tx-1', retryCount: 2 });
+ mockGetItem.mockResolvedValue(JSON.stringify([entry]));
+
+ await pendingQueue.updateStatus('tx-1', 'pending', 3, Date.now());
+ const saved = JSON.parse(mockSetItem.mock.calls[0][1]);
+ expect(saved[0].retryCount).toBe(3);
+ });
+ });
+
+ describe('remove', () => {
+ it('removes an entry by id', async () => {
+ const entries = [
+ createMockQueue({ id: 'tx-1' }),
+ createMockQueue({ id: 'tx-2' }),
+ ];
+ mockGetItem.mockResolvedValue(JSON.stringify(entries));
+
+ await pendingQueue.remove('tx-1');
+ const saved = JSON.parse(mockSetItem.mock.calls[0][1]);
+ expect(saved).toHaveLength(1);
+ expect(saved[0].id).toBe('tx-2');
+ });
+ });
+
+ describe('get / getByTxHash', () => {
+ it('retrieves an entry by id', async () => {
+ const entries = [createMockQueue({ id: 'tx-1' })];
+ mockGetItem.mockResolvedValue(JSON.stringify(entries));
+
+ const result = await pendingQueue.get('tx-1');
+ expect(result).not.toBeNull();
+ expect(result!.id).toBe('tx-1');
+ });
+
+ it('retrieves an entry by txHash', async () => {
+ const entries = [createMockQueue({ id: 'tx-1', txHash: '0xhaha' })];
+ mockGetItem.mockResolvedValue(JSON.stringify(entries));
+
+ const result = await pendingQueue.getByTxHash('0xhaha');
+ expect(result).not.toBeNull();
+ expect(result!.id).toBe('tx-1');
+ });
+ });
+
+ describe('getAll / getPending', () => {
+ it('returns all entries', async () => {
+ const entries = [
+ createMockQueue({ id: 'tx-1', status: 'pending' }),
+ createMockQueue({ id: 'tx-2', status: 'confirmed' }),
+ createMockQueue({ id: 'tx-3', status: 'failed' }),
+ ];
+ mockGetItem.mockResolvedValue(JSON.stringify(entries));
+
+ const all = await pendingQueue.getAll();
+ expect(all).toHaveLength(3);
+ });
+
+ it('returns only pending entries', async () => {
+ const entries = [
+ createMockQueue({ id: 'tx-1', status: 'pending' }),
+ createMockQueue({ id: 'tx-2', status: 'confirmed' }),
+ createMockQueue({ id: 'tx-3', status: 'pending' }),
+ ];
+ mockGetItem.mockResolvedValue(JSON.stringify(entries));
+
+ const pendings = await pendingQueue.getPending();
+ expect(pendings).toHaveLength(2);
+ expect(pendings.every((t: { status: string }) => t.status === 'pending')).toBe(true);
+ });
+ });
+
+ describe('getSummary', () => {
+ it('returns counts grouped by status', async () => {
+ const entries = [
+ createMockQueue({ id: 'tx-1', status: 'pending' }),
+ createMockQueue({ id: 'tx-2', status: 'confirmed' }),
+ createMockQueue({ id: 'tx-3', status: 'failed' }),
+ createMockQueue({ id: 'tx-4', status: 'pending' }),
+ createMockQueue({ id: 'tx-5', status: 'expired' }),
+ ];
+ mockGetItem.mockResolvedValue(JSON.stringify(entries));
+
+ const summary = await pendingQueue.getSummary();
+ expect(summary).toEqual({
+ total: 5,
+ pending: 2,
+ confirmed: 1,
+ failed: 1,
+ expired: 1,
+ });
+ });
+ });
+
+ describe('clear', () => {
+ it('removes all entries from storage', async () => {
+ await pendingQueue.clear();
+ expect(mockRemoveItem).toHaveBeenCalledWith(QUEUE_KEY);
+ });
+ });
+});
diff --git a/src/transactions/pending-queue.ts b/src/transactions/pending-queue.ts
new file mode 100644
index 0000000..a52203b
--- /dev/null
+++ b/src/transactions/pending-queue.ts
@@ -0,0 +1,158 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import type {
+ PendingTransaction,
+ PendingTransactionStatus,
+ PendingTransactionType,
+} from '../../types/transaction.types';
+
+const QUEUE_KEY = '@stepfi/pending-transactions';
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function generateId(): string {
+ return Date.now().toString(36) + Math.random().toString(36).substring(2, 11);
+}
+
+// ─── Storage ──────────────────────────────────────────────────────────────────
+
+/**
+ * Read the full pending‑transaction queue from storage.
+ * Returns an empty array on any error.
+ */
+async function loadAll(): Promise {
+ try {
+ const raw = await AsyncStorage.getItem(QUEUE_KEY);
+ return raw ? JSON.parse(raw) : [];
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Persist the full queue back to storage.
+ */
+async function saveAll(txns: PendingTransaction[]): Promise {
+ await AsyncStorage.setItem(QUEUE_KEY, JSON.stringify(txns));
+}
+
+// ─── Public API ───────────────────────────────────────────────────────────────
+
+export const pendingQueue = {
+ /**
+ * Add a new pending transaction to the queue.
+ * Returns the newly created entry with its auto‑generated id and timestamp.
+ */
+ async add(tx: {
+ txHash: string;
+ type: PendingTransactionType;
+ targetLoanId?: string;
+ targetInstallmentIndex?: number;
+ amount?: number;
+ }): Promise {
+ const queue = await loadAll();
+ const entry: PendingTransaction = {
+ id: generateId(),
+ txHash: tx.txHash,
+ type: tx.type,
+ targetLoanId: tx.targetLoanId,
+ targetInstallmentIndex: tx.targetInstallmentIndex,
+ amount: tx.amount,
+ status: 'pending',
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
+ retryCount: 0,
+ };
+ queue.push(entry);
+ await saveAll(queue);
+ return entry;
+ },
+
+ /**
+ * Update the status (and optionally retryCount) of a queue entry.
+ * Returns `true` if the entry was found and updated, `false` otherwise.
+ */
+ async updateStatus(
+ id: string,
+ status: PendingTransactionStatus,
+ retryCount?: number,
+ lastPolledAt?: number,
+ ): Promise {
+ const queue = await loadAll();
+ const idx = queue.findIndex((t) => t.id === id);
+ if (idx === -1) return false;
+
+ queue[idx].status = status;
+ queue[idx].updatedAt = Date.now();
+ if (retryCount !== undefined) queue[idx].retryCount = retryCount;
+ if (lastPolledAt !== undefined) queue[idx].lastPolledAt = lastPolledAt;
+ await saveAll(queue);
+ return true;
+ },
+
+ /**
+ * Remove an entry from the queue by id.
+ */
+ async remove(id: string): Promise {
+ const queue = await loadAll();
+ const filtered = queue.filter((t) => t.id !== id);
+ await saveAll(filtered);
+ },
+
+ /**
+ * Retrieve a single entry by id.
+ */
+ async get(id: string): Promise {
+ const queue = await loadAll();
+ return queue.find((t) => t.id === id) ?? null;
+ },
+
+ /**
+ * Retrieve an entry by its on‑chain tx hash.
+ */
+ async getByTxHash(txHash: string): Promise {
+ const queue = await loadAll();
+ return queue.find((t) => t.txHash === txHash) ?? null;
+ },
+
+ /**
+ * Return all entries from the queue.
+ */
+ async getAll(): Promise {
+ return loadAll();
+ },
+
+ /**
+ * Return only entries whose status is 'pending' (i.e. still in flight).
+ */
+ async getPending(): Promise {
+ const queue = await loadAll();
+ return queue.filter((t) => t.status === 'pending');
+ },
+
+ /**
+ * Return a count of entries grouped by status.
+ */
+ async getSummary(): Promise<{
+ total: number;
+ pending: number;
+ confirmed: number;
+ failed: number;
+ expired: number;
+ }> {
+ const queue = await loadAll();
+ return {
+ total: queue.length,
+ pending: queue.filter((t) => t.status === 'pending').length,
+ confirmed: queue.filter((t) => t.status === 'confirmed').length,
+ failed: queue.filter((t) => t.status === 'failed').length,
+ expired: queue.filter((t) => t.status === 'expired').length,
+ };
+ },
+
+ /**
+ * Clear all entries (e.g. after a full reconciliation).
+ */
+ async clear(): Promise {
+ await AsyncStorage.removeItem(QUEUE_KEY);
+ },
+};
diff --git a/src/transactions/transaction-poller.ts b/src/transactions/transaction-poller.ts
new file mode 100644
index 0000000..db78af9
--- /dev/null
+++ b/src/transactions/transaction-poller.ts
@@ -0,0 +1,240 @@
+import { transactionsService } from '../../services/transactions.service';
+import { addBreadcrumb } from '../../services/sentry';
+import { pendingQueue } from './pending-queue';
+import { useLoansStore } from '../../stores/loans.store';
+
+// ─── Constants ────────────────────────────────────────────────────────────────
+
+/** Base interval in ms before the first retry. */
+const BASE_INTERVAL_MS = 2_000;
+
+/** Maximum interval cap so we don't keep polling forever at high frequency. */
+const MAX_INTERVAL_MS = 60_000;
+
+/** How many consecutive failures before we give up on a tx and mark expired. */
+const MAX_RETRIES = 15;
+
+/** Key under which the poller stores its timer id on the global scope. */
+const POLLER_TIMER_KEY = '__stepfi_tx_poller_timer';
+
+// ─── Concurrency Guard ────────────────────────────────────────────────────────
+
+/** Per‑tx lock to prevent the same tx from being polled concurrently. */
+const inFlightLocks = new Map>();
+
+/**
+ * Execute `fn` for a given tx id, ensuring only one poll at a time per tx.
+ * Concurrent calls for the same id will await the in‑flight promise.
+ */
+async function withLock(txId: string, fn: () => Promise): Promise {
+ const existing = inFlightLocks.get(txId);
+ if (existing) {
+ // Already being polled — wait for the in‑flight attempt to complete
+ return existing;
+ }
+
+ const promise = fn().finally(() => {
+ // Only clean up if our promise is still the one in the map
+ if (inFlightLocks.get(txId) === promise) {
+ inFlightLocks.delete(txId);
+ }
+ });
+
+ inFlightLocks.set(txId, promise);
+ return promise;
+}
+
+// ─── Backoff Calculation ──────────────────────────────────────────────────────
+
+function getBackoffDelay(retryCount: number): number {
+ const delay = Math.min(BASE_INTERVAL_MS * 2 ** retryCount, MAX_INTERVAL_MS);
+ return delay;
+}
+
+// ─── Polling Logic ────────────────────────────────────────────────────────────
+
+/**
+ * Poll a single pending transaction. If the API returns a terminal status the
+ * queue entry is updated and the loan store is reconciled idempotently.
+ */
+async function pollOne(tx: {
+ id: string;
+ txHash: string;
+ targetLoanId?: string;
+ targetInstallmentIndex?: number;
+ amount?: number;
+}): Promise {
+ try {
+ const result = await transactionsService.getTxStatus(tx.txHash);
+
+ switch (result.status) {
+ case 'confirmed': {
+ await pendingQueue.updateStatus(tx.id, 'confirmed', undefined, Date.now());
+ addBreadcrumb('tx.poller', 'Tx confirmed', {
+ txHash: tx.txHash,
+ loanId: tx.targetLoanId,
+ });
+
+ // Sync updated pending list into the store for live UI
+ const updated = await pendingQueue.getPending();
+ useLoansStore.getState().setPendingTransactions(
+ await pendingQueue.getAll(),
+ );
+
+ // Idempotent loan‑store update — pass txHash as idempotency key
+ if (tx.targetLoanId && tx.targetInstallmentIndex !== undefined) {
+ useLoansStore.getState().markInstallmentPaid(
+ tx.txHash,
+ tx.targetLoanId,
+ tx.targetInstallmentIndex,
+ );
+ }
+ break;
+ }
+
+ case 'failed': {
+ await pendingQueue.updateStatus(tx.id, 'failed', 0, Date.now());
+ addBreadcrumb('tx.poller', 'Tx failed', {
+ txHash: tx.txHash,
+ message: result.message,
+ });
+
+ useLoansStore.getState().setPendingTransactions(
+ await pendingQueue.getAll(),
+ );
+ break;
+ }
+
+ case 'expired': {
+ await pendingQueue.updateStatus(tx.id, 'expired', 0, Date.now());
+ addBreadcrumb('tx.poller', 'Tx expired', {
+ txHash: tx.txHash,
+ });
+
+ useLoansStore.getState().setPendingTransactions(
+ await pendingQueue.getAll(),
+ );
+ break;
+ }
+
+ case 'pending':
+ default: {
+ // Still pending — increment retry count for backoff
+ const entry = await pendingQueue.get(tx.id);
+ if (!entry) return;
+ const nextRetry = entry.retryCount + 1;
+ await pendingQueue.updateStatus(tx.id, 'pending', nextRetry, Date.now());
+
+ // If we have exceeded the max retries, mark as expired
+ if (nextRetry >= MAX_RETRIES) {
+ await pendingQueue.updateStatus(tx.id, 'expired', nextRetry, Date.now());
+ addBreadcrumb('tx.poller', 'Max retries reached — expired', {
+ txHash: tx.txHash,
+ retries: nextRetry,
+ });
+
+ useLoansStore.getState().setPendingTransactions(
+ await pendingQueue.getAll(),
+ );
+ }
+ break;
+ }
+ }
+ } catch {
+ // Network-level error — increment retry and let the scheduler re‑visit
+ const entry = await pendingQueue.get(tx.id);
+ if (!entry) return;
+ const nextRetry = entry.retryCount + 1;
+ await pendingQueue.updateStatus(tx.id, 'pending', nextRetry, Date.now());
+ }
+}
+
+// ─── Scheduler ────────────────────────────────────────────────────────────────
+
+/**
+ * Execute a single sweep of the pending queue: poll each pending tx at its
+ * current backoff interval. Entries that are not yet due for a retry are
+ * skipped.
+ */
+async function sweep(): Promise {
+ const pendings = await pendingQueue.getPending();
+ if (pendings.length === 0) return;
+
+ const now = Date.now();
+
+ for (const tx of pendings) {
+ const delay = getBackoffDelay(tx.retryCount);
+ const elapsed = tx.lastPolledAt ? now - tx.lastPolledAt : Infinity;
+
+ // Only poll this tx if enough time has passed since the last attempt
+ if (elapsed >= delay) {
+ // Wrap with concurrency lock to prevent double-polling
+ withLock(tx.id, () => pollOne(tx)).catch(() => {});
+ }
+ }
+}
+
+// ─── Start / Stop ─────────────────────────────────────────────────────────────
+
+let _active = false;
+
+/**
+ * Start the background poller. It runs a sweep every `BASE_INTERVAL_MS` and
+ * automatically stops when the queue is empty.
+ */
+export function startTxPoller(): void {
+ if (_active) return;
+ _active = true;
+
+ addBreadcrumb('tx.poller', 'Poller started');
+
+ // @ts-expect-error — storing timer id on global scope for cross‑module access
+ globalThis[POLLER_TIMER_KEY] = setInterval(() => {
+ sweep().catch(() => {});
+ }, BASE_INTERVAL_MS);
+
+ // Also kick off an immediate sweep
+ sweep().catch(() => {});
+}
+
+/**
+ * Stop the background poller.
+ */
+export function stopTxPoller(): void {
+ _active = false;
+ // @ts-expect-error
+ const timer = globalThis[POLLER_TIMER_KEY] as ReturnType | undefined;
+ if (timer !== undefined) {
+ clearInterval(timer);
+ // @ts-expect-error
+ delete globalThis[POLLER_TIMER_KEY];
+ }
+ addBreadcrumb('tx.poller', 'Poller stopped');
+}
+
+// ─── Reconciliation (on app resume) ───────────────────────────────────────────
+
+/**
+ * Force‑poll every pending transaction immediately and reconcile loan state.
+ * Called when the app returns from background / kill.
+ */
+export async function reconcilePendingTxs(): Promise {
+ addBreadcrumb('tx.poller', 'Reconciliation started');
+ const pendings = await pendingQueue.getPending();
+
+ if (pendings.length === 0) {
+ addBreadcrumb('tx.poller', 'No pending txs to reconcile');
+ return;
+ }
+
+ addBreadcrumb('tx.poller', `Reconciling ${pendings.length} pending txs`);
+
+ // Poll all in parallel for fast reconciliation, protected by per-tx locks
+ const results = await Promise.allSettled(
+ pendings.map((tx) => withLock(tx.id, () => pollOne(tx))),
+ );
+
+ const ok = results.filter((r) => r.status === 'fulfilled').length;
+ const failed = results.filter((r) => r.status === 'rejected').length;
+ addBreadcrumb('tx.poller', 'Reconciliation complete', { ok, failed });
+}
diff --git a/stores/__tests__/loans.store.test.ts b/stores/__tests__/loans.store.test.ts
new file mode 100644
index 0000000..45313bc
--- /dev/null
+++ b/stores/__tests__/loans.store.test.ts
@@ -0,0 +1,169 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import type { Loan } from '../../types/loan.types';
+
+// Mock AsyncStorage before any imports that depend on it
+jest.mock('@react-native-async-storage/async-storage', () => ({
+ getItem: jest.fn(),
+ setItem: jest.fn(),
+ removeItem: jest.fn(),
+ getAllKeys: jest.fn(),
+ multiRemove: jest.fn(),
+}));
+
+// We need to use require for the store because it has module-level persist init
+let useLoansStore: typeof import('../loans.store').useLoansStore;
+let store: ReturnType;
+
+function createMockLoan(overrides: Partial = {}): Loan {
+ return {
+ id: 'loan-1',
+ walletAddress: 'GABCDEF123456789',
+ vendorId: 'vendor-1',
+ totalAmount: 1000,
+ remainingBalance: 500,
+ status: 'active',
+ loanType: 'learner_installment',
+ installments: [
+ { dueDate: '2026-01-01', amount: 200, paid: false },
+ { dueDate: '2026-02-01', amount: 200, paid: false },
+ { dueDate: '2026-03-01', amount: 200, paid: false },
+ { dueDate: '2026-04-01', amount: 200, paid: false },
+ { dueDate: '2026-05-01', amount: 200, paid: false },
+ ],
+ createdAt: '2025-12-01T00:00:00Z',
+ ...overrides,
+ };
+}
+
+describe('LoansStore — markInstallmentPaid idempotency', () => {
+ beforeAll(async () => {
+ // Reset mocks before importing the store
+ jest.clearAllMocks();
+ (AsyncStorage.getItem as jest.Mock).mockResolvedValue(null);
+ (AsyncStorage.setItem as jest.Mock).mockResolvedValue(undefined);
+
+ useLoansStore = (await import('../loans.store')).useLoansStore;
+ });
+
+ beforeEach(() => {
+ // Reset store state before each test
+ useLoansStore.setState({
+ loans: [],
+ selectedLoan: null,
+ isLoading: false,
+ simulatedAmount: null,
+ simulatedTerm: null,
+ processedTxHashes: [],
+ pendingTransactions: [],
+ });
+ });
+
+ describe('markInstallmentPaid', () => {
+ it('marks an installment as paid and recalculates remaining balance', () => {
+ const loan = createMockLoan();
+ useLoansStore.getState().setLoans([loan]);
+
+ useLoansStore.getState().markInstallmentPaid(
+ 'tx-1',
+ 'loan-1',
+ 0, // first installment
+ );
+
+ const state = useLoansStore.getState();
+ const updatedLoan = state.loans[0];
+
+ // First installment should be paid
+ expect(updatedLoan.installments[0].paid).toBe(true);
+ expect(updatedLoan.installments[0].paidAt).toBeDefined();
+
+ // Remaining balance should be reduced by the installment amount (200)
+ expect(updatedLoan.remainingBalance).toBe(loan.totalAmount - 200);
+
+ // Processed tx hash should be tracked
+ expect(state.processedTxHashes).toContain('tx-1');
+ });
+
+ it('is idempotent — calling with the same txHash twice does not double-count', () => {
+ const loan = createMockLoan();
+ useLoansStore.getState().setLoans([loan]);
+
+ // First call
+ useLoansStore.getState().markInstallmentPaid('tx-1', 'loan-1', 0);
+ const stateAfterFirst = useLoansStore.getState();
+ const remainingAfterFirst = stateAfterFirst.loans[0].remainingBalance;
+
+ // Second call with same txHash — should be no-op
+ useLoansStore.getState().markInstallmentPaid('tx-1', 'loan-1', 0);
+ const stateAfterSecond = useLoansStore.getState();
+
+ expect(stateAfterSecond.loans[0].remainingBalance).toBe(remainingAfterFirst);
+ expect(stateAfterSecond.processedTxHashes).toHaveLength(1);
+ });
+
+ it('auto-transitions loan status to "paid" when all installments are paid', () => {
+ const loan = createMockLoan(); // 5 installments, $200 each
+ useLoansStore.getState().setLoans([loan]);
+
+ // Pay all installments with unique tx hashes
+ useLoansStore.getState().markInstallmentPaid('tx-1', 'loan-1', 0);
+ useLoansStore.getState().markInstallmentPaid('tx-2', 'loan-1', 1);
+ useLoansStore.getState().markInstallmentPaid('tx-3', 'loan-1', 2);
+ useLoansStore.getState().markInstallmentPaid('tx-4', 'loan-1', 3);
+ useLoansStore.getState().markInstallmentPaid('tx-5', 'loan-1', 4);
+
+ const state = useLoansStore.getState();
+ expect(state.loans[0].status).toBe('paid');
+ expect(state.loans[0].remainingBalance).toBe(0);
+ expect(state.loans[0].installments.every((i) => i.paid)).toBe(true);
+ });
+
+ it('does not modify other loans when updating', () => {
+ const loan1 = createMockLoan({ id: 'loan-1' });
+ const loan2 = createMockLoan({ id: 'loan-2', totalAmount: 2000, remainingBalance: 1000 });
+ useLoansStore.getState().setLoans([loan1, loan2]);
+
+ useLoansStore.getState().markInstallmentPaid('tx-1', 'loan-1', 0);
+
+ const state = useLoansStore.getState();
+ expect(state.loans[1].remainingBalance).toBe(1000); // Unchanged
+ expect(state.loans[1].installments.every((i) => !i.paid)).toBe(true);
+ });
+
+ it('is a no-op if the loan does not exist', () => {
+ useLoansStore.getState().setLoans([createMockLoan()]);
+
+ // Should not throw and should not affect existing loans
+ expect(() => {
+ useLoansStore.getState().markInstallmentPaid('tx-404', 'nonexistent-loan', 0);
+ }).not.toThrow();
+
+ const state = useLoansStore.getState();
+ expect(state.loans).toHaveLength(1);
+ expect(state.loans[0].installments.every((i) => !i.paid)).toBe(true);
+ });
+ });
+
+ describe('persistence (partialize)', () => {
+ it('persists loans, simulatedAmount, simulatedTerm, and processedTxHashes', async () => {
+ // Store persist middleware calls setItem on state changes
+ // Set some state
+ useLoansStore.getState().setLoans([createMockLoan()]);
+ useLoansStore.getState().saveSimulation(5000, 6);
+ useLoansStore.getState().markInstallmentPaid('tx-1', 'loan-1', 0);
+
+ // Verify AsyncStorage.setItem was called with the persisted state
+ expect(AsyncStorage.setItem).toHaveBeenCalledWith(
+ '@stepfi/loans-store',
+ expect.any(String),
+ );
+
+ const savedData = JSON.parse((AsyncStorage.setItem as jest.Mock).mock.calls[0][1]);
+
+ // Should persist these fields
+ expect(savedData.state.loans).toHaveLength(1);
+ expect(savedData.state.simulatedAmount).toBe(5000);
+ expect(savedData.state.simulatedTerm).toBe(6);
+ expect(savedData.state.processedTxHashes).toContain('tx-1');
+ });
+ });
+});
diff --git a/stores/loans.store.ts b/stores/loans.store.ts
index 7e5ef16..7a62955 100644
--- a/stores/loans.store.ts
+++ b/stores/loans.store.ts
@@ -1,5 +1,11 @@
import { create } from 'zustand';
+import { persist, createJSONStorage } from 'zustand/middleware';
+import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Loan } from '../types/loan.types';
+import type { PendingTransaction } from '../types/transaction.types';
+
+/** Maximum number of processed tx hashes kept in the persisted store to prevent unbounded growth. */
+const MAX_PROCESSED_HASHES = 200;
interface LoansState {
loans: Loan[];
@@ -9,30 +15,121 @@ interface LoansState {
selectLoan: (id: string) => void;
clearLoans: () => void;
setLoading: (loading: boolean) => void;
-
- // Persisted simulation state
+
+ // ─── Persisted simulation state ────────────────────────────────────────
simulatedAmount: number | null;
simulatedTerm: number | null;
saveSimulation: (amount: number, term: number) => void;
+
+ // ─── Idempotent updates ───────────────────────────────────────────────
+ /** Set of tx hashes that have already been reconciled so we never double‑count. */
+ processedTxHashes: string[];
+
+ /**
+ * Idempotently mark an installment as paid. If `txHash` has already been
+ * processed this is a no‑op, preventing double‑counting on reconnect.
+ */
+ markInstallmentPaid: (
+ txHash: string,
+ loanId: string,
+ installmentIndex: number,
+ ) => void;
+
+ // ─── Pending transaction sync ──────────────────────────────────────────
+ pendingTransactions: PendingTransaction[];
+ setPendingTransactions: (txns: PendingTransaction[]) => void;
}
-export const useLoansStore = create((set, get) => ({
- loans: [],
- selectedLoan: null,
- isLoading: false,
- simulatedAmount: null,
- simulatedTerm: null,
+export const useLoansStore = create()(
+ persist(
+ (set, get) => ({
+ loans: [],
+ selectedLoan: null,
+ isLoading: false,
+ simulatedAmount: null,
+ simulatedTerm: null,
+
+ processedTxHashes: [],
+ pendingTransactions: [],
+
+ setLoans: (loans) => set({ loans }),
+
+ selectLoan: (id) => {
+ const loan = get().loans.find((l) => l.id === id) ?? null;
+ set({ selectedLoan: loan });
+ },
+
+ clearLoans: () =>
+ set({
+ loans: [],
+ selectedLoan: null,
+ pendingTransactions: [],
+ processedTxHashes: [],
+ }),
+
+ setLoading: (isLoading) => set({ isLoading }),
+
+ saveSimulation: (simulatedAmount, simulatedTerm) =>
+ set({ simulatedAmount, simulatedTerm }),
+
+ markInstallmentPaid: (txHash, loanId, installmentIndex) => {
+ const state = get();
+
+ // Idempotency guard — skip if we already processed this tx hash
+ if (state.processedTxHashes.includes(txHash)) return;
+
+ const updatedLoans = state.loans.map((loan) => {
+ if (loan.id !== loanId) return loan;
+
+ const updatedInstallments = loan.installments.map((inst, idx) => {
+ if (idx !== installmentIndex) return inst;
+ return { ...inst, paid: true, paidAt: new Date().toISOString() };
+ });
+
+ // Recalculate remaining balance
+ const paidTotal = updatedInstallments
+ .filter((i) => i.paid)
+ .reduce((sum, i) => sum + i.amount, 0);
+ const remainingBalance = loan.totalAmount - paidTotal;
- setLoans: (loans) => set({ loans }),
+ // Auto‑transition status if fully paid
+ const allPaid = updatedInstallments.every((i) => i.paid);
+ const status = allPaid ? ('paid' as const) : loan.status;
- selectLoan: (id) => {
- const loan = get().loans.find((l) => l.id === id) ?? null;
- set({ selectedLoan: loan });
- },
+ return {
+ ...loan,
+ installments: updatedInstallments,
+ remainingBalance: Math.max(0, remainingBalance),
+ status,
+ };
+ });
- clearLoans: () => set({ loans: [], selectedLoan: null }),
+ // Keep the processed list bounded — trim oldest entries if over limit
+ const updatedHashes = [...state.processedTxHashes, txHash];
+ const trimmedHashes =
+ updatedHashes.length > MAX_PROCESSED_HASHES
+ ? updatedHashes.slice(updatedHashes.length - MAX_PROCESSED_HASHES)
+ : updatedHashes;
- setLoading: (isLoading) => set({ isLoading }),
+ set({
+ loans: updatedLoans,
+ processedTxHashes: trimmedHashes,
+ });
+ },
- saveSimulation: (simulatedAmount, simulatedTerm) => set({ simulatedAmount, simulatedTerm }),
-}));
+ setPendingTransactions: (pendingTransactions) => set({ pendingTransactions }),
+ }),
+ {
+ name: '@stepfi/loans-store',
+ storage: createJSONStorage(() => AsyncStorage),
+ // Only persist these fields — derived / transient state stays in memory
+ partialize: (state) => ({
+ loans: state.loans,
+ simulatedAmount: state.simulatedAmount,
+ simulatedTerm: state.simulatedTerm,
+ processedTxHashes: state.processedTxHashes,
+ pendingTransactions: state.pendingTransactions,
+ }),
+ },
+ ),
+);
diff --git a/types/transaction.types.ts b/types/transaction.types.ts
index a4191cd..5927837 100644
--- a/types/transaction.types.ts
+++ b/types/transaction.types.ts
@@ -32,3 +32,48 @@ export interface TransactionResult {
txHash: string;
signedXdr: string;
}
+
+// ─── Pending Transaction Tracking ────────────────────────────────────────────
+
+export type PendingTransactionStatus =
+ | 'pending'
+ | 'confirmed'
+ | 'failed'
+ | 'expired';
+
+export type PendingTransactionType =
+ | 'LOAN_CREATION'
+ | 'REPAYMENT'
+ | 'VOUCH_SUBMIT'
+ | 'LIQUIDITY_DEPOSIT';
+
+export interface PendingTransaction {
+ /** Unique local id for the queue entry. */
+ id: string;
+ /** On-chain transaction hash returned after submission. */
+ txHash: string;
+ /** High-level category for the transaction. */
+ type: PendingTransactionType;
+ /** Target loan this tx affects (if applicable). */
+ targetLoanId?: string;
+ /** Specific installment index within the loan (if repayment). */
+ targetInstallmentIndex?: number;
+ /** Amount involved in the tx (for display / idempotency). */
+ amount?: number;
+ /** Current resolution status. */
+ status: PendingTransactionStatus;
+ /** ISO timestamp when the entry was first created. */
+ createdAt: number;
+ /** ISO timestamp of the last status change. */
+ updatedAt: number;
+ /** How many times we have polled so far (for backoff). */
+ retryCount: number;
+ /** ISO timestamp of the last poll attempt. */
+ lastPolledAt?: number;
+}
+
+/** Public-facing summary surfaced to the user. */
+export interface PendingSummary {
+ pendingCount: number;
+ recent: PendingTransaction[];
+}