diff --git a/mobileapp/__tests__/TransferConfirmationCard.test.tsx b/mobileapp/__tests__/TransferConfirmationCard.test.tsx
index 676883f2..1e0560e4 100644
--- a/mobileapp/__tests__/TransferConfirmationCard.test.tsx
+++ b/mobileapp/__tests__/TransferConfirmationCard.test.tsx
@@ -148,4 +148,38 @@ describe("TransferConfirmationCard", () => {
expect(getByText("Privacy")).toBeTruthy();
expect(getByText("FRIENDS")).toBeTruthy();
});
+
+ it("renders real name and username parameters when real name is provided", () => {
+ const { getByText, getByTestId } = render(
+
+ );
+
+ expect(getByTestId("confirmation-real-name")).toBeTruthy();
+ expect(getByText("Tolu Adebayo")).toBeTruthy();
+ expect(getByText("tolu.zaps")).toBeTruthy();
+ });
+
+ it("displays verification badge for trusted profiles", () => {
+ const { getByLabelText } = render(
+
+ );
+
+ expect(getByLabelText("Verified recipient")).toBeTruthy();
+ });
});
diff --git a/mobileapp/__tests__/api.test.ts b/mobileapp/__tests__/api.test.ts
index 0beac847..aa577531 100644
--- a/mobileapp/__tests__/api.test.ts
+++ b/mobileapp/__tests__/api.test.ts
@@ -3,6 +3,10 @@ import {
completePrivySignup,
linkPrivyAddress,
stellarAddressFromPrivyPublicKey,
+ getRecentRecipients,
+ saveRecentRecipient,
+ getCachedRecentRecipientsSync,
+ clearRecentRecipientsCache,
} from "../src/services/api";
jest.mock("@react-native-async-storage/async-storage", () => ({
@@ -181,3 +185,48 @@ describe("Privy signup API service", () => {
expect(mockAsyncStorage.setItem).not.toHaveBeenCalled();
});
});
+
+describe("Recent recipient local cache helper (Issue #914)", () => {
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ await clearRecentRecipientsCache();
+ });
+
+ it("stores and fetches recent recipients from local persistent storage", async () => {
+ await saveRecentRecipient("alice.zaps");
+ await saveRecentRecipient("bob.zaps");
+
+ const cached = await getRecentRecipients();
+ expect(cached).toEqual(["bob.zaps", "alice.zaps"]);
+ expect(mockAsyncStorage.setItem).toHaveBeenCalledWith(
+ "recent_recipient_usernames",
+ JSON.stringify(["bob.zaps", "alice.zaps"])
+ );
+ });
+
+ it("fetches from cache instantly without waiting for network", () => {
+ saveRecentRecipient("instant.zaps");
+ const syncResult = getCachedRecentRecipientsSync();
+ expect(syncResult).toContain("instant.zaps");
+ });
+
+ it("limits cached contacts to 20 maximum", async () => {
+ for (let i = 1; i <= 25; i++) {
+ await saveRecentRecipient(`user${i}.zaps`);
+ }
+
+ const cached = await getRecentRecipients();
+ expect(cached.length).toBe(20);
+ expect(cached[0]).toBe("user25.zaps");
+ expect(cached).not.toContain("user1.zaps");
+ });
+
+ it("deduplicates and moves re-used recipient to the top", async () => {
+ await saveRecentRecipient("user1.zaps");
+ await saveRecentRecipient("user2.zaps");
+ await saveRecentRecipient("user1.zaps");
+
+ const cached = await getRecentRecipients();
+ expect(cached).toEqual(["user1.zaps", "user2.zaps"]);
+ });
+});
diff --git a/mobileapp/__tests__/transactions.test.tsx b/mobileapp/__tests__/transactions.test.tsx
new file mode 100644
index 00000000..b073d725
--- /dev/null
+++ b/mobileapp/__tests__/transactions.test.tsx
@@ -0,0 +1,126 @@
+import React from "react";
+import { render, fireEvent } from "@testing-library/react-native";
+import TransactionsScreen from "../app/(personal)/transactions";
+import type { Transaction } from "../src/types/transaction";
+
+jest.mock("@react-native-async-storage/async-storage", () =>
+ require("@react-native-async-storage/async-storage/jest/async-storage-mock")
+);
+
+jest.mock("expo-router", () => ({
+ useRouter: jest.fn(() => ({
+ push: jest.fn(),
+ back: jest.fn(),
+ })),
+}));
+
+jest.mock("../src/services/stellarWallet", () => ({
+ getLocalKeypair: jest.fn(() => null),
+ checkFreighter: jest.fn(() => Promise.resolve(false)),
+ speedUpTransaction: jest.fn(() => Promise.resolve()),
+}));
+
+jest.mock("../src/components/TransactionFilterSheet", () => ({
+ TransactionFilterSheet: () => null,
+}));
+
+const mockTransactions: Transaction[] = [
+ {
+ id: "tx-sdp-1",
+ type: "mass_payout",
+ status: "completed",
+ amount: "100.00",
+ asset: "USDC",
+ fiatValue: "150,000",
+ fiatCurrency: "NGN",
+ address: "GABC1234567890DEF1234567890ABCDEF1234567890ABCDEF1234567890AB",
+ addressLabel: "Relief Payout",
+ timestamp: new Date().toISOString(),
+ senderName: "Mercy Relief Org",
+ senderAnchor: "Anchor Global",
+ },
+ {
+ id: "tx-standard-1",
+ type: "sent",
+ status: "completed",
+ amount: "25.00",
+ asset: "XLM",
+ fiatValue: "5,000",
+ fiatCurrency: "NGN",
+ address: "GDEF1234567890DEF1234567890ABCDEF1234567890ABCDEF1234567890CD",
+ addressLabel: "Tolu",
+ timestamp: new Date().toISOString(),
+ },
+ {
+ id: "tx-standard-2",
+ type: "received",
+ status: "completed",
+ amount: "50.00",
+ asset: "USDC",
+ fiatValue: "75,000",
+ fiatCurrency: "NGN",
+ address: "GXYZ1234567890DEF1234567890ABCDEF1234567890ABCDEF1234567890EF",
+ addressLabel: "Amina",
+ timestamp: new Date().toISOString(),
+ },
+];
+
+const mockSetFilters = jest.fn();
+const mockRefresh = jest.fn();
+const mockLoadMore = jest.fn();
+
+jest.mock("../src/hooks/useTransactions", () => ({
+ useTransactions: () => ({
+ transactions: mockTransactions,
+ loading: false,
+ refreshing: false,
+ loadingMore: false,
+ filters: { type: "all", status: "all", search: "" },
+ setFilters: mockSetFilters,
+ refresh: mockRefresh,
+ loadMore: mockLoadMore,
+ }),
+}));
+
+describe("TransactionsScreen Disbursement History (Issue #921)", () => {
+ it("renders distinct section grouping for Received Mass Payouts separate from standard transfers", () => {
+ const { getByTestId, getByText } = render();
+
+ expect(getByTestId("mass-payout-section-header")).toBeTruthy();
+ expect(getByText("Received Mass Payouts (SDP)")).toBeTruthy();
+ expect(getByText("Standard Transfers")).toBeTruthy();
+ });
+
+ it("renders unique badge and label for SDP disbursements", () => {
+ const { getByTestId, getByText } = render();
+
+ expect(getByTestId("sdp-disbursement-badge")).toBeTruthy();
+ expect(getByText("SDP Disbursement")).toBeTruthy();
+ });
+
+ it("renders sender company and anchor information for mass payouts", () => {
+ const { getByText, getByTestId } = render();
+
+ expect(getByText("Mercy Relief Org")).toBeTruthy();
+ expect(getByTestId("sender-anchor-info")).toBeTruthy();
+ expect(getByText("Anchor: Anchor Global")).toBeTruthy();
+ });
+
+ it("applies conditional list item rendering based on transaction type", () => {
+ const { getByTestId } = render();
+
+ expect(getByTestId("tx-row-tx-sdp-1")).toBeTruthy();
+ expect(getByTestId("tx-row-tx-standard-1")).toBeTruthy();
+ expect(getByTestId("tx-row-tx-standard-2")).toBeTruthy();
+ });
+
+ it("switches view when SDP Disbursements tab is selected", () => {
+ const { getByTestId, queryByText } = render();
+
+ const sdpTab = getByTestId("mass-payout-tab");
+ fireEvent.press(sdpTab);
+
+ expect(getByTestId("mass-payout-section-header")).toBeTruthy();
+ expect(queryByText("Standard Transfers")).toBeNull();
+ });
+});
diff --git a/mobileapp/app/(personal)/history.tsx b/mobileapp/app/(personal)/history.tsx
index d8aa373b..715871ca 100644
--- a/mobileapp/app/(personal)/history.tsx
+++ b/mobileapp/app/(personal)/history.tsx
@@ -183,16 +183,16 @@ const TransactionRow = React.memo(function TransactionRow({
{isMassPayout && item.senderName ? item.senderName : label}
{isMassPayout && (
-
-
- Mass Payout
+
+
+ SDP Disbursement
)}
{/* Anchor/sender info for SDP disbursements */}
- {isMassPayout && item.senderAnchor && (
-
- via {item.senderAnchor}
+ {isMassPayout && (
+
+ {item.senderAnchor ? `via ${item.senderAnchor}` : "Stellar Disbursement Platform"}
)}
diff --git a/mobileapp/app/(personal)/home.tsx b/mobileapp/app/(personal)/home.tsx
index 80fdc6bf..67a47907 100644
--- a/mobileapp/app/(personal)/home.tsx
+++ b/mobileapp/app/(personal)/home.tsx
@@ -1027,14 +1027,22 @@ export default function HomeScreen() {
-
- Auto-Earn on Idle Funds
-
+
+ Auto-Earn on Idle Funds
+
+
+
{
+ if (val) {
+ openAutoEarnDescModal();
+ } else {
+ void handleAutoYieldToggle(false);
+ }
+ }}
disabled={autoEarnSyncing}
trackColor={{ false: "#E2E8F0", true: "#34D399" }}
thumbColor={COLORS.white}
@@ -1059,6 +1073,19 @@ export default function HomeScreen() {
accessibilityRole="switch"
/>
+ {autoEarnTooltipVisible && (
+
+
+
+ Auto-Earn automatically sweeps idle funds into yield-earning vaults at competitive APYs while remaining instantly spendable.
+
+
+ )}
{autoEarnSyncing && (
diff --git a/mobileapp/app/(personal)/transactions.tsx b/mobileapp/app/(personal)/transactions.tsx
index 832480f6..a52a0c99 100644
--- a/mobileapp/app/(personal)/transactions.tsx
+++ b/mobileapp/app/(personal)/transactions.tsx
@@ -1,13 +1,1041 @@
-/**
- * transactions.tsx
- *
- * Full transaction list screen that supports section grouping,
- * distinguishing SDP "Mass Payout" disbursements from standard transfers.
- *
- * Renders the same HistoryScreen used in the tab navigator, available as
- * a standalone route for deep-link and programmatic navigation use-cases.
- *
- * Issue: #576 — Design transaction list section grouping
- * Received Mass Payouts distinct from standard transfers.
- */
-export { default } from "./history";
+import React, { useEffect, useState, useCallback, useMemo } from "react";
+import {
+ View,
+ Text,
+ StyleSheet,
+ FlatList,
+ TouchableOpacity,
+ TextInput,
+ ActivityIndicator,
+ RefreshControl,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { Ionicons, Feather } from "@expo/vector-icons";
+import { useRouter } from "expo-router";
+import { COLORS } from "../../src/constants/colors";
+import { useTransactions } from "../../src/hooks/useTransactions";
+import { TransactionFilterSheet } from "../../src/components/TransactionFilterSheet";
+import { Transaction, TransactionFilters } from "../../src/types/transaction";
+import { formatDate } from "../../src/utils/formatting";
+import {
+ speedUpTransaction,
+ getLocalKeypair,
+ checkFreighter,
+} from "../../src/services/stellarWallet";
+
+// Time threshold to consider a pending transaction stuck (1 minute)
+const STUCK_PENDING_MS = 60 * 1000;
+
+// Yield transaction history filtering presets
+const DATE_PRESETS: { label: string; days: number }[] = [
+ { label: "7D", days: 7 },
+ { label: "30D", days: 30 },
+ { label: "90D", days: 90 },
+ { label: "1Y", days: 365 },
+];
+
+function presetStartIso(days: number): string {
+ const d = new Date();
+ d.setDate(d.getDate() - days);
+ d.setHours(0, 0, 0, 0);
+ return d.toISOString().slice(0, 10);
+}
+
+function isPresetActive(filters: TransactionFilters, days: number): boolean {
+ return filters.dateFrom === presetStartIso(days) && !filters.dateTo;
+}
+
+function canSpeedUp(tx: Transaction): boolean {
+ if (tx.status !== "pending" || !tx.stellarTxXdr) return false;
+ return Date.now() - new Date(tx.timestamp).getTime() > STUCK_PENDING_MS;
+}
+
+async function resolveWalletSource(): Promise<
+ "freighter" | "albedo" | "local"
+> {
+ const kp = await getLocalKeypair();
+ if (kp) return "local";
+ if (await checkFreighter()) return "freighter";
+ return "local";
+}
+
+function statusColor(status: Transaction["status"]): string {
+ switch (status) {
+ case "completed":
+ return "#22C55E";
+ case "pending":
+ return "#F59E0B";
+ case "failed":
+ return "#EF4444";
+ }
+}
+
+function typeIcon(type: Transaction["type"]) {
+ switch (type) {
+ case "sent":
+ return { name: "arrow-up" as const, bg: "#FFEBEE", color: "#EF4444" };
+ case "received":
+ return { name: "arrow-down" as const, bg: "#E8F5E9", color: "#22C55E" };
+ case "yield":
+ return { name: "trending-up" as const, bg: "#F0FDF4", color: "#16A34A" };
+ case "mass_payout":
+ return {
+ name: "people" as const,
+ bg: "#EDE9FE",
+ color: "#7C3AED",
+ };
+ default:
+ return {
+ name: "swap-horizontal" as const,
+ bg: "#E3F2FD",
+ color: "#2196F3",
+ };
+ }
+}
+
+function yieldTagLabel(yieldType: Transaction["yieldType"]): string {
+ switch (yieldType) {
+ case "interest":
+ return "Interest";
+ case "reward":
+ return "Reward";
+ case "apy":
+ return "APY";
+ default:
+ return "Yield";
+ }
+}
+
+function truncateAddress(addr: string): string {
+ if (!addr) return "";
+ if (addr.length <= 20) return addr;
+ return `${addr.slice(0, 8)}…${addr.slice(-6)}`;
+}
+
+function hasActiveFilters(f: TransactionFilters): boolean {
+ return (
+ f.type !== "all" ||
+ f.status !== "all" ||
+ Boolean(f.dateFrom) ||
+ Boolean(f.dateTo) ||
+ Boolean(f.amountMin) ||
+ Boolean(f.amountMax) ||
+ Boolean(f.yieldOnly) ||
+ Boolean(f.asset)
+ );
+}
+
+// Transaction Row with conditional rendering logic based on transaction type
+const TransactionRow = React.memo(function TransactionRow({
+ item,
+ onPress,
+ onSpeedUp,
+ speedingUp,
+}: {
+ item: Transaction;
+ onPress: () => void;
+ onSpeedUp: (tx: Transaction) => void;
+ speedingUp: boolean;
+}) {
+ const isMassPayout = item.type === "mass_payout";
+ const isOutgoing = item.type === "sent";
+ const isYield = item.type === "yield";
+ const icon = typeIcon(item.type);
+ const time = formatDate(item.timestamp, {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ const showSpeedUp = canSpeedUp(item);
+
+ return (
+
+ {/* Icon with conditional styling */}
+
+
+
+
+ {/* Info Column */}
+
+ {/* Label row with conditional SDP disbursement badge */}
+
+
+ {isMassPayout
+ ? item.senderName || item.addressLabel || "Disbursing Organization"
+ : item.addressLabel ?? truncateAddress(item.address)}
+
+
+ {isMassPayout && (
+
+
+ SDP Disbursement
+
+ )}
+
+
+ {/* Sender information: company and anchor name for SDP mass payouts */}
+ {isMassPayout && (
+
+
+
+ {item.senderAnchor
+ ? `Anchor: ${item.senderAnchor}`
+ : "Stellar Disbursement Platform"}
+
+
+ )}
+
+
+ {time}
+
+ {isYield && (
+
+
+
+ {yieldTagLabel(item.yieldType)}
+
+
+ )}
+
+ {item.status !== "completed" && (
+
+
+ {item.status}
+
+
+ )}
+
+
+ {showSpeedUp && (
+ onSpeedUp(item)}
+ disabled={speedingUp}
+ activeOpacity={0.8}
+ accessibilityRole="button"
+ accessibilityLabel={`Speed up ${item.amount} ${item.asset}`}
+ >
+ {speedingUp ? (
+
+ ) : (
+ <>
+
+ Speed up
+ >
+ )}
+
+ )}
+
+
+ {/* Amount Column */}
+
+
+ {isOutgoing ? "−" : "+"}
+ {item.amount} {item.asset}
+
+
+ {item.fiatValue} {item.fiatCurrency}
+
+
+
+ );
+});
+
+// Section Header components
+function SectionHeader({ title }: { title: string }) {
+ return (
+
+ {title}
+
+ );
+}
+
+function MassPayoutSectionHeader({ count }: { count?: number }) {
+ return (
+
+
+
+
+
+
+ Received Mass Payouts (SDP)
+
+
+ {count !== undefined && count > 0 && (
+
+ {count}
+
+ )}
+
+ );
+}
+
+// Group transactions with distinct section for Received Mass Payouts
+type ListItem =
+ | { kind: "mass_payout_header"; key: string; count: number }
+ | { kind: "section_header"; key: string; title: string }
+ | { kind: "date_header"; key: string; title: string }
+ | { kind: "tx"; key: string; tx: Transaction };
+
+function groupTransactions(
+ txs: Transaction[],
+ sectionFilter: "all" | "mass_payout" | "transfers"
+): ListItem[] {
+ const result: ListItem[] = [];
+
+ const safeTxs = Array.isArray(txs) ? txs : [];
+ const massPayouts = safeTxs.filter((t) => t.type === "mass_payout");
+ const standardTransfers = safeTxs.filter((t) => t.type !== "mass_payout");
+
+ // If filtering to mass payouts only
+ if (sectionFilter === "mass_payout") {
+ if (massPayouts.length > 0) {
+ result.push({
+ kind: "mass_payout_header",
+ key: "mp_main_header",
+ count: massPayouts.length,
+ });
+ for (const tx of massPayouts) {
+ result.push({ kind: "tx", key: tx.id, tx });
+ }
+ }
+ return result;
+ }
+
+ // If filtering to standard transfers only
+ if (sectionFilter === "transfers") {
+ let lastDate = "";
+ for (const tx of standardTransfers) {
+ const d = new Date(tx.timestamp);
+ const today = new Date();
+ const yesterday = new Date(today);
+ yesterday.setDate(today.getDate() - 1);
+
+ let label: string;
+ if (d.toDateString() === today.toDateString()) {
+ label = "Today";
+ } else if (d.toDateString() === yesterday.toDateString()) {
+ label = "Yesterday";
+ } else {
+ label = formatDate(tx.timestamp, {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ });
+ }
+
+ if (label !== lastDate) {
+ lastDate = label;
+ result.push({ kind: "date_header", key: `hdr_${label}`, title: label });
+ }
+ result.push({ kind: "tx", key: tx.id, tx });
+ }
+ return result;
+ }
+
+ // Default "all" mode: Group Received Mass Payouts in a distinct prominent section,
+ // followed by Standard Transfers grouped by date.
+ if (massPayouts.length > 0) {
+ result.push({
+ kind: "mass_payout_header",
+ key: "mp_main_header",
+ count: massPayouts.length,
+ });
+ for (const tx of massPayouts) {
+ result.push({ kind: "tx", key: tx.id, tx });
+ }
+ }
+
+ if (standardTransfers.length > 0) {
+ result.push({
+ kind: "section_header",
+ key: "sec_standard_transfers",
+ title: "Standard Transfers",
+ });
+
+ let lastDate = "";
+ for (const tx of standardTransfers) {
+ const d = new Date(tx.timestamp);
+ const today = new Date();
+ const yesterday = new Date(today);
+ yesterday.setDate(today.getDate() - 1);
+
+ let label: string;
+ if (d.toDateString() === today.toDateString()) {
+ label = "Today";
+ } else if (d.toDateString() === yesterday.toDateString()) {
+ label = "Yesterday";
+ } else {
+ label = formatDate(tx.timestamp, {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ });
+ }
+
+ if (label !== lastDate) {
+ lastDate = label;
+ result.push({ kind: "date_header", key: `hdr_${label}`, title: label });
+ }
+ result.push({ kind: "tx", key: tx.id, tx });
+ }
+ }
+
+ return result;
+}
+
+// Full transactions screen with Disbursement history section
+export default function TransactionsScreen() {
+ const router = useRouter();
+ const {
+ transactions,
+ loading,
+ refreshing,
+ loadingMore,
+ filters,
+ setFilters,
+ refresh,
+ loadMore,
+ } = useTransactions();
+
+ const [filterVisible, setFilterVisible] = useState(false);
+ const [speedingUpId, setSpeedingUpId] = useState(null);
+ const [speedUpError, setSpeedUpError] = useState(null);
+ const [sectionFilter, setSectionFilter] = useState<
+ "all" | "mass_payout" | "transfers"
+ >("all");
+ const [searchText, setSearchText] = useState(filters.search);
+
+ // Sync search input with hook filters
+ const handleSearch = (text: string) => {
+ setSearchText(text);
+ setFilters((prev) => ({ ...prev, search: text }));
+ };
+
+ const handleSpeedUp = useCallback(async (tx: Transaction) => {
+ if (!tx.stellarTxXdr) return;
+ setSpeedingUpId(tx.id);
+ setSpeedUpError(null);
+ try {
+ const source = await resolveWalletSource();
+ await speedUpTransaction(tx.stellarTxXdr, source);
+ await refresh();
+ } catch (err) {
+ setSpeedUpError(
+ err instanceof Error ? err.message : "Failed to speed up transaction"
+ );
+ } finally {
+ setSpeedingUpId(null);
+ }
+ }, [refresh]);
+
+ const items = useMemo(
+ () => groupTransactions(transactions, sectionFilter),
+ [transactions, sectionFilter]
+ );
+
+ const filtersActive = hasActiveFilters(filters);
+
+ const renderItem = useCallback(
+ ({ item }: { item: ListItem }) => {
+ if (item.kind === "mass_payout_header") {
+ return ;
+ }
+ if (item.kind === "section_header" || item.kind === "date_header") {
+ return ;
+ }
+ return (
+ router.push(`/transaction/${item.tx.id}` as any)}
+ onSpeedUp={handleSpeedUp}
+ speedingUp={speedingUpId === item.tx.id}
+ />
+ );
+ },
+ [router, handleSpeedUp, speedingUpId]
+ );
+
+ const ListFooter = () => {
+ if (!loadingMore) return null;
+ return (
+
+
+
+ );
+ };
+
+ const ListEmpty = () => {
+ if (loading) return null;
+ return (
+
+
+ No transactions
+
+ {filtersActive || searchText
+ ? "Try adjusting your filters or search."
+ : "Your transactions and mass payouts will appear here."}
+
+
+ );
+ };
+
+ return (
+
+ {/* Header */}
+
+ Transactions & Disbursements
+ setFilterVisible(true)}
+ accessibilityLabel="Open filters"
+ >
+
+ {filtersActive && }
+
+
+
+ {/* Search Bar */}
+
+
+
+
+ {searchText.length > 0 && (
+ handleSearch("")}
+ style={styles.clearBtn}
+ >
+
+
+ )}
+
+
+
+ {/* Section Switcher Tabs */}
+
+ setSectionFilter("all")}
+ accessibilityRole="button"
+ >
+
+ All Activity
+
+
+
+ setSectionFilter("mass_payout")}
+ accessibilityRole="button"
+ testID="mass-payout-tab"
+ >
+
+
+ SDP Disbursements
+
+
+
+ setSectionFilter("transfers")}
+ accessibilityRole="button"
+ >
+
+ Transfers
+
+
+
+
+ {/* Date Presets Row */}
+
+ {DATE_PRESETS.map(({ label, days }) => {
+ const active = isPresetActive(filters, days);
+ return (
+ {
+ setFilters((prev) => ({
+ ...prev,
+ dateFrom: active ? undefined : presetStartIso(days),
+ dateTo: undefined,
+ }));
+ }}
+ accessibilityLabel={`Filter by last ${label}`}
+ >
+
+ {label}
+
+
+ );
+ })}
+
+
+ {/* Speed up error banner */}
+ {Boolean(speedUpError) && (
+
+
+ {speedUpError}
+ setSpeedUpError(null)}>
+
+
+
+ )}
+
+ {/* Main List */}
+ item.key}
+ ListFooterComponent={ListFooter}
+ ListEmptyComponent={ListEmpty}
+ refreshControl={
+
+ }
+ onEndReached={loadMore}
+ onEndReachedThreshold={0.3}
+ contentContainerStyle={[
+ styles.listContent,
+ items.length === 0 && styles.listContentEmpty,
+ ]}
+ />
+
+ {/* Filter Sheet */}
+ setFilterVisible(false)}
+ filters={filters}
+ onApply={(applied) => setFilters(applied)}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: "#F9FAFB",
+ },
+ header: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingHorizontal: 20,
+ paddingVertical: 14,
+ backgroundColor: COLORS.white,
+ borderBottomWidth: 1,
+ borderBottomColor: "#F3F4F6",
+ },
+ headerTitle: {
+ fontSize: 20,
+ fontFamily: "Outfit_700Bold",
+ color: COLORS.black,
+ },
+ filterBtn: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ backgroundColor: "#F3F4F6",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ filterBtnActive: {
+ backgroundColor: COLORS.primary,
+ },
+ filterDot: {
+ position: "absolute",
+ top: 8,
+ right: 8,
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ backgroundColor: "#22C55E",
+ },
+ searchRow: {
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ backgroundColor: COLORS.white,
+ },
+ searchBox: {
+ flexDirection: "row",
+ alignItems: "center",
+ backgroundColor: "#F3F4F6",
+ borderRadius: 12,
+ paddingHorizontal: 12,
+ height: 42,
+ },
+ searchIcon: {
+ marginRight: 8,
+ },
+ searchInput: {
+ flex: 1,
+ fontSize: 14,
+ fontFamily: "Outfit_400Regular",
+ color: COLORS.black,
+ },
+ clearBtn: {
+ padding: 4,
+ },
+ sectionTabsRow: {
+ flexDirection: "row",
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ backgroundColor: COLORS.white,
+ gap: 8,
+ borderBottomWidth: 1,
+ borderBottomColor: "#F3F4F6",
+ },
+ sectionTabBtn: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: 12,
+ paddingVertical: 6,
+ borderRadius: 20,
+ backgroundColor: "#F3F4F6",
+ },
+ sectionTabBtnActive: {
+ backgroundColor: COLORS.primary,
+ },
+ sectionTabText: {
+ fontSize: 13,
+ fontFamily: "Outfit_500Medium",
+ color: "#6B7280",
+ },
+ sectionTabTextActive: {
+ color: COLORS.secondary,
+ fontFamily: "Outfit_600SemiBold",
+ },
+ presetsRow: {
+ flexDirection: "row",
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ gap: 8,
+ backgroundColor: COLORS.white,
+ borderBottomWidth: 1,
+ borderBottomColor: "#F3F4F6",
+ },
+ presetBtn: {
+ paddingHorizontal: 12,
+ paddingVertical: 4,
+ borderRadius: 16,
+ backgroundColor: "#F3F4F6",
+ },
+ presetBtnActive: {
+ backgroundColor: COLORS.secondary,
+ },
+ presetBtnText: {
+ fontSize: 12,
+ fontFamily: "Outfit_500Medium",
+ color: "#6B7280",
+ },
+ presetBtnTextActive: {
+ color: COLORS.primary,
+ fontFamily: "Outfit_600SemiBold",
+ },
+ listContent: {
+ paddingBottom: 40,
+ },
+ listContentEmpty: {
+ flexGrow: 1,
+ },
+ txRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ paddingHorizontal: 16,
+ paddingVertical: 14,
+ backgroundColor: COLORS.white,
+ borderBottomWidth: 1,
+ borderBottomColor: "#F3F4F6",
+ },
+ txRowMassPayout: {
+ backgroundColor: "#FAFAFE",
+ },
+ txIcon: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ justifyContent: "center",
+ alignItems: "center",
+ marginRight: 12,
+ },
+ txInfo: {
+ flex: 1,
+ marginRight: 8,
+ },
+ txLabelRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ flexWrap: "wrap",
+ gap: 6,
+ },
+ txLabel: {
+ fontSize: 15,
+ fontFamily: "Outfit_600SemiBold",
+ color: COLORS.black,
+ maxWidth: "70%",
+ },
+ sdpBadge: {
+ flexDirection: "row",
+ alignItems: "center",
+ backgroundColor: "#EDE9FE",
+ paddingHorizontal: 6,
+ paddingVertical: 2,
+ borderRadius: 10,
+ gap: 3,
+ },
+ sdpBadgeText: {
+ fontSize: 11,
+ fontFamily: "Outfit_600SemiBold",
+ color: "#7C3AED",
+ },
+ senderAnchorRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 4,
+ marginTop: 2,
+ },
+ senderAnchorText: {
+ fontSize: 12,
+ fontFamily: "Outfit_400Regular",
+ color: "#6B7280",
+ },
+ txMeta: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 6,
+ marginTop: 4,
+ },
+ txTime: {
+ fontSize: 12,
+ fontFamily: "Outfit_400Regular",
+ color: "#9CA3AF",
+ },
+ yieldTag: {
+ flexDirection: "row",
+ alignItems: "center",
+ backgroundColor: "#F0FDF4",
+ paddingHorizontal: 6,
+ paddingVertical: 1,
+ borderRadius: 6,
+ gap: 2,
+ },
+ yieldTagText: {
+ fontSize: 11,
+ fontFamily: "Outfit_500Medium",
+ color: "#16A34A",
+ },
+ statusPill: {
+ paddingHorizontal: 6,
+ paddingVertical: 1,
+ borderRadius: 6,
+ },
+ statusPillText: {
+ fontSize: 11,
+ fontFamily: "Outfit_500Medium",
+ textTransform: "capitalize",
+ },
+ speedUpBtn: {
+ flexDirection: "row",
+ alignItems: "center",
+ alignSelf: "flex-start",
+ backgroundColor: COLORS.primary,
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ borderRadius: 8,
+ marginTop: 6,
+ },
+ speedUpBtnText: {
+ fontSize: 12,
+ fontFamily: "Outfit_500Medium",
+ color: COLORS.white,
+ },
+ txAmount: {
+ alignItems: "flex-end",
+ },
+ txAmountText: {
+ fontSize: 15,
+ fontFamily: "Outfit_700Bold",
+ },
+ txFiat: {
+ fontSize: 12,
+ fontFamily: "Outfit_400Regular",
+ color: "#9CA3AF",
+ marginTop: 2,
+ },
+ sectionHeader: {
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ backgroundColor: "#F9FAFB",
+ },
+ sectionHeaderText: {
+ fontSize: 12,
+ fontFamily: "Outfit_600SemiBold",
+ color: "#6B7280",
+ textTransform: "uppercase",
+ letterSpacing: 0.5,
+ },
+ massPayoutSectionHeader: {
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ backgroundColor: "#F5F3FF",
+ borderLeftWidth: 4,
+ borderLeftColor: "#7C3AED",
+ },
+ massPayoutHeaderLeft: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: 8,
+ },
+ massPayoutHeaderIconWrap: {
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ backgroundColor: "#EDE9FE",
+ justifyContent: "center",
+ alignItems: "center",
+ },
+ massPayoutSectionHeaderText: {
+ fontSize: 13,
+ fontFamily: "Outfit_700Bold",
+ color: "#7C3AED",
+ },
+ massPayoutCountBadge: {
+ backgroundColor: "#7C3AED",
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 10,
+ },
+ massPayoutCountText: {
+ fontSize: 11,
+ fontFamily: "Outfit_600SemiBold",
+ color: COLORS.white,
+ },
+ speedUpErrorBanner: {
+ flexDirection: "row",
+ alignItems: "center",
+ backgroundColor: "#FEE2E2",
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ gap: 8,
+ },
+ speedUpErrorText: {
+ flex: 1,
+ fontSize: 12,
+ fontFamily: "Outfit_400Regular",
+ color: "#EF4444",
+ },
+ footerLoader: {
+ paddingVertical: 20,
+ alignItems: "center",
+ },
+ emptyContainer: {
+ flex: 1,
+ justifyContent: "center",
+ alignItems: "center",
+ paddingTop: 80,
+ paddingHorizontal: 40,
+ },
+ emptyTitle: {
+ fontSize: 17,
+ fontFamily: "Outfit_600SemiBold",
+ color: "#374151",
+ marginTop: 16,
+ },
+ emptySubtext: {
+ fontSize: 13,
+ fontFamily: "Outfit_400Regular",
+ color: "#9CA3AF",
+ textAlign: "center",
+ marginTop: 6,
+ },
+});
diff --git a/mobileapp/app/transfer.tsx b/mobileapp/app/transfer.tsx
index 3bd96d08..1416a9a8 100644
--- a/mobileapp/app/transfer.tsx
+++ b/mobileapp/app/transfer.tsx
@@ -44,7 +44,11 @@ import {
StellarWalletState,
type TransactionFeeEstimate,
} from "../src/services/stellarWallet";
-import { getRecentRecipients, saveRecentRecipient } from "../src/services/api";
+import {
+ getCachedRecentRecipientsSync,
+ getRecentRecipients,
+ saveRecentRecipient,
+} from "../src/services/api";
import { isFederatedAddress } from "../src/utils/sep0007";
// #580 — Biometric Verification Overlay
import { authenticateWithBiometrics } from "./biometric";
@@ -316,7 +320,9 @@ function TransferScreen() {
// Recipient search state
const [searchResults, setSearchResults] = useState([]);
- const [recentRecipients, setRecentRecipients] = useState([]);
+ const [recentRecipients, setRecentRecipients] = useState(() =>
+ getCachedRecentRecipientsSync()
+ );
const [searching, setSearching] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const [selectedUser, setSelectedUser] = useState(null);
@@ -399,12 +405,27 @@ function TransferScreen() {
setUsernameStatus("idle");
return;
}
- const body: { username: string; address: string; is_blacklisted?: boolean } =
- await res.json();
+ const body: {
+ username: string;
+ address: string;
+ is_blacklisted?: boolean;
+ display_name?: string;
+ name?: string;
+ avatar_url?: string;
+ is_verified?: boolean;
+ } = await res.json();
if (body.is_blacklisted) {
setUsernameStatus("blacklisted");
} else {
setUsernameStatus("registered");
+ setSelectedUser((prev: ZapsUser | null) => ({
+ username: body.username,
+ address: body.address,
+ avatar_url: body.avatar_url ?? prev?.avatar_url ?? null,
+ name: body.display_name ?? body.name ?? prev?.name ?? null,
+ display_name: body.display_name ?? prev?.display_name ?? null,
+ isVerified: body.is_verified ?? prev?.isVerified ?? false,
+ }));
}
} catch {
// Network error — don't block the user from proceeding
@@ -524,6 +545,8 @@ function TransferScreen() {
setRecipient(contact.username);
setSelectedUser({
username: contact.username,
+ name: contact.contactName,
+ display_name: contact.contactName,
address: contact.address,
avatar_url: contact.avatarUrl ?? null,
isVerified: false,
@@ -658,6 +681,63 @@ function TransferScreen() {
[]
);
+ const processConfirmedTransfer = async () => {
+ // Biometric Verification Overlay
+ // Gate the payment submission behind biometric / passcode authentication.
+ // The transfer is blocked unless the user successfully authenticates.
+ const biometricResult = await authenticateWithBiometrics(
+ "Verify your identity to confirm the transfer"
+ );
+
+ if (!biometricResult.success) {
+ if (biometricResult.reason === "cancelled") {
+ return;
+ }
+ Alert.alert(
+ "Authentication Required",
+ "You must verify your identity before sending a payment. Please try again.",
+ [{ text: "OK" }]
+ );
+ return;
+ }
+
+ setSubmitting(true);
+ try {
+ const assetIssuer =
+ token.symbol !== "XLM"
+ ? STELLAR_ASSET_ISSUERS[token.symbol]
+ : undefined;
+ const result = await submitPayment(
+ recipient,
+ amount,
+ walletState!.source!,
+ walletState!.publicKey,
+ token.symbol,
+ assetIssuer,
+ description || undefined
+ );
+ const stored = await AsyncStorage.getItem("pending_transfers");
+ const list = stored ? JSON.parse(stored) : [];
+ list.unshift({
+ recipient,
+ amount,
+ description: description || "Sent money",
+ visibility,
+ token: token.symbol,
+ hash: result.hash,
+ });
+ await AsyncStorage.setItem("pending_transfers", JSON.stringify(list));
+ await saveRecentRecipient(recipient);
+ } catch (e) {
+ Alert.alert("Transfer Failed", (e as Error).message);
+ setSubmitting(false);
+ return;
+ }
+ setSubmitting(false);
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
+ setStep(4);
+ };
+
const handleNext = async () => {
if (step === 0 && transferType === "BATCH") {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
@@ -676,61 +756,27 @@ function TransferScreen() {
}
if (step === 3) {
- // #580 — Biometric Verification Overlay
- // Gate the payment submission behind biometric / passcode authentication.
- // The transfer is blocked unless the user successfully authenticates.
- const biometricResult = await authenticateWithBiometrics(
- "Verify your identity to confirm the transfer"
+ const recipientDisplayName =
+ selectedUser?.name ||
+ selectedUser?.display_name ||
+ selectedUser?.real_name;
+ const recipientLabel = recipientDisplayName
+ ? `${recipientDisplayName} (@${recipient})`
+ : recipient;
+
+ Alert.alert(
+ "Confirm Transfer",
+ `Are you sure you want to send ${amount} ${token.symbol} to ${recipientLabel}?`,
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Confirm",
+ onPress: () => {
+ void processConfirmedTransfer();
+ },
+ },
+ ]
);
-
- if (!biometricResult.success) {
- if (biometricResult.reason === "cancelled") {
- // User deliberately dismissed the prompt — do nothing, stay on step 3.
- return;
- }
- Alert.alert(
- "Authentication Required",
- "You must verify your identity before sending a payment. Please try again.",
- [{ text: "OK" }]
- );
- return;
- }
-
- setSubmitting(true);
- try {
- const assetIssuer =
- token.symbol !== "XLM"
- ? STELLAR_ASSET_ISSUERS[token.symbol]
- : undefined;
- const result = await submitPayment(
- recipient,
- amount,
- walletState!.source!,
- walletState!.publicKey,
- token.symbol,
- assetIssuer,
- description || undefined
- );
- const stored = await AsyncStorage.getItem("pending_transfers");
- const list = stored ? JSON.parse(stored) : [];
- list.unshift({
- recipient,
- amount,
- description: description || "Sent money",
- visibility,
- token: token.symbol,
- hash: result.hash,
- });
- await AsyncStorage.setItem("pending_transfers", JSON.stringify(list));
- await saveRecentRecipient(recipient);
- } catch (e) {
- Alert.alert("Transfer Failed", (e as Error).message);
- setSubmitting(false);
- return;
- }
- setSubmitting(false);
- LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
- setStep(4);
return;
}
diff --git a/mobileapp/src/components/TransferConfirmationCard.tsx b/mobileapp/src/components/TransferConfirmationCard.tsx
index 889dfef6..e9e98e5e 100644
--- a/mobileapp/src/components/TransferConfirmationCard.tsx
+++ b/mobileapp/src/components/TransferConfirmationCard.tsx
@@ -37,13 +37,35 @@ export const TransferConfirmationCard = React.memo(
return `${Number.isFinite(numeric) ? numeric.toLocaleString() : "0"} stroops`;
};
+ const realName =
+ recipient.name || recipient.display_name || recipient.real_name;
+
return (
-
+
+
+
+ {Boolean(realName) && (
+
+ {realName}
+
+ )}
-
- {recipient.username}
- {recipient.isVerified && }
+
+
+ {recipient.username}
+
+ {recipient.isVerified && }
+
{recipient.address && (
@@ -155,16 +177,31 @@ const styles = StyleSheet.create({
borderColor: "#F0F0F0",
alignItems: "center",
},
+ recipientHeader: {
+ alignItems: "center",
+ marginTop: 14,
+ },
+ realName: {
+ fontSize: 20,
+ fontFamily: "Outfit_700Bold",
+ color: COLORS.black,
+ textAlign: "center",
+ },
nameRow: {
flexDirection: "row",
alignItems: "center",
- marginTop: 16,
+ marginTop: 4,
},
username: {
fontSize: 20,
fontFamily: "Outfit_700Bold",
color: COLORS.black,
},
+ usernameSubtitle: {
+ fontSize: 14,
+ fontFamily: "Outfit_500Medium",
+ color: "#666",
+ },
address: {
fontSize: 13,
fontFamily: "Outfit_400Regular",
diff --git a/mobileapp/src/services/api.ts b/mobileapp/src/services/api.ts
index ac9e98a8..3b180cba 100644
--- a/mobileapp/src/services/api.ts
+++ b/mobileapp/src/services/api.ts
@@ -149,37 +149,66 @@ export async function apiFetch(
return response;
}
-// ── Recent recipients (unchanged; still uses AsyncStorage for non-sensitive data) ──
-
+// Local cache for frequently paid usernames with AsyncStorage persistence
const RECENT_RECIPIENTS_KEY = "recent_recipient_usernames";
const MAX_RECENT_RECIPIENTS = 20;
+let inMemoryRecentRecipients: string[] | null = null;
+
+export function getCachedRecentRecipientsSync(): string[] {
+ return inMemoryRecentRecipients ? [...inMemoryRecentRecipients] : [];
+}
+
export async function getRecentRecipients(): Promise {
+ if (inMemoryRecentRecipients !== null) {
+ return [...inMemoryRecentRecipients];
+ }
try {
const raw = await AsyncStorage.getItem(RECENT_RECIPIENTS_KEY);
- return raw ? (JSON.parse(raw) as string[]) : [];
+ const parsed = raw ? (JSON.parse(raw) as string[]) : [];
+ inMemoryRecentRecipients = Array.isArray(parsed)
+ ? parsed.slice(0, MAX_RECENT_RECIPIENTS)
+ : [];
+ return [...inMemoryRecentRecipients];
} catch {
return [];
}
}
export async function saveRecentRecipient(username: string): Promise {
- if (!username) return;
+ const trimmed = username ? username.trim() : "";
+ if (!trimmed) return;
try {
- const raw = await AsyncStorage.getItem(RECENT_RECIPIENTS_KEY);
- const current = raw ? (JSON.parse(raw) as string[]) : [];
+ let current = inMemoryRecentRecipients;
+ if (current === null) {
+ const raw = await AsyncStorage.getItem(RECENT_RECIPIENTS_KEY);
+ const parsed = raw ? (JSON.parse(raw) as string[]) : [];
+ current = Array.isArray(parsed) ? parsed : [];
+ }
const next = [
- username,
- ...current.filter((item) => item !== username),
+ trimmed,
+ ...current.filter((item) => item.toLowerCase() !== trimmed.toLowerCase()),
].slice(0, MAX_RECENT_RECIPIENTS);
+ inMemoryRecentRecipients = next;
await AsyncStorage.setItem(RECENT_RECIPIENTS_KEY, JSON.stringify(next));
} catch {
- // Ignore cache failures.
+ // Ignore cache failures
}
}
-// ── Yield API ─────────────────────────────────────────────────────────────────
+export const cacheRecentRecipient = saveRecentRecipient;
+export const getFrequentlyPaidUsernames = getRecentRecipients;
+export async function clearRecentRecipientsCache(): Promise {
+ inMemoryRecentRecipients = [];
+ try {
+ await AsyncStorage.removeItem(RECENT_RECIPIENTS_KEY);
+ } catch {
+ // Ignore cache failures
+ }
+}
+
+// Yield API
export interface YieldBalance {
apy: string | number;
totalYieldEarned: string | number;
@@ -197,12 +226,18 @@ export async function fetchYieldBalance(): Promise {
export async function updateAutoEarn(enabled: boolean): Promise {
try {
- await apiFetch(`${API_BASE}/api/yield/auto-earn`, {
- method: "PATCH",
+ const res = await apiFetch(`${API_BASE}/api/yield/toggle-auto`, {
+ method: "POST",
body: JSON.stringify({ enabled }),
});
+ if (!res.ok) {
+ await apiFetch(`${API_BASE}/api/yield/auto-earn`, {
+ method: "PATCH",
+ body: JSON.stringify({ enabled }),
+ }).catch(() => {});
+ }
} catch {
- // Non-fatal — local state already reflects the toggle
+ // Non-fatal: local state already reflects the toggle
}
}
diff --git a/mobileapp/src/types/user.ts b/mobileapp/src/types/user.ts
index e04c5156..aa152de6 100644
--- a/mobileapp/src/types/user.ts
+++ b/mobileapp/src/types/user.ts
@@ -2,6 +2,10 @@ export interface ZapsUser {
username: string;
address: string;
avatar_url: string | null;
- /** Placeholder — real trust data needs to be wired in by the API layer. */
+ name?: string | null;
+ display_name?: string | null;
+ real_name?: string | null;
+ /** Placeholder: real trust data needs to be wired in by the API layer. */
isVerified?: boolean;
}
+