diff --git a/mobile/app/PocketSwitch.tsx b/mobile/app/PocketSwitch.tsx
index 863368592..34007bf96 100644
--- a/mobile/app/PocketSwitch.tsx
+++ b/mobile/app/PocketSwitch.tsx
@@ -7,44 +7,43 @@ import { SafeAreaView } from 'react-native-safe-area-context';
import DetachedSheet from '@/components/DetachedSheet';
import { ThemedText } from '@/components/ThemedText';
+import { BackgroundExecutor } from '@/src/modules/background-executor';
import { AccountItem, AccountNumberContext, accountItems } from '@shared/hooks/AccountNumberContext';
import { NetworkContext } from '@shared/hooks/NetworkContext';
import { useAccountBalance } from '@shared/hooks/useAccountBalance';
import { useAvailableNetworks } from '@shared/hooks/useAvailableNetworks';
+import { useSparkUsdbEarnMetrics } from '@shared/hooks/useSparkUsdbEarnMetrics';
import { getDecimalsByNetwork, getTickerByNetwork } from '@shared/models/network-getters';
import { formatBalance, formatFiatBalance } from '@shared/modules/string-utils';
import { NETWORK_BITCOIN } from '@shared/types/networks';
import { useExchangeRate } from '@shared/hooks/useExchangeRate';
import { overlayBackgroundSections } from '@shared/constants/Colors';
-const TotalBalanceSection = () => {
- const availableNetworks = useAvailableNetworks();
- const { exchangeRate } = useExchangeRate(NETWORK_BITCOIN, 'USD');
-
- // Get balances for all accounts (hooks must be called unconditionally)
- const { accountBalance: balance0 } = useAccountBalance(0, availableNetworks);
- const { accountBalance: balance1 } = useAccountBalance(1, availableNetworks);
- const { accountBalance: balance2 } = useAccountBalance(2, availableNetworks);
- const { accountBalance: balance3 } = useAccountBalance(3, availableNetworks);
- const { accountBalance: balance4 } = useAccountBalance(4, availableNetworks);
-
- const totalBalance = useMemo(() => {
- const balances = [balance0, balance1, balance2, balance3, balance4].slice(0, accountItems.length);
- return balances.reduce((sum, bal) => sum + (parseInt(bal) || 0), 0).toString();
- }, [balance0, balance1, balance2, balance3, balance4]);
-
- const totalUsd = totalBalance && exchangeRate ? formatFiatBalance(totalBalance, getDecimalsByNetwork(NETWORK_BITCOIN), exchangeRate) : '—';
-
+const TotalBalanceSection = ({ totalUsdDisplay }: { totalUsdDisplay: string }) => {
return (
Total balance
- ${totalUsd}
+ ${totalUsdDisplay}
);
};
-const ListItem = ({ item, onPress, accountNumber, currentAccountNumber }: { item: AccountItem; onPress: () => void; accountNumber: number; currentAccountNumber: number }) => {
+
+const ListItem = ({
+ item,
+ onPress,
+ accountNumber,
+ currentAccountNumber,
+ sparkUsdbAllocatedUsd,
+}: {
+ item: AccountItem;
+ onPress: () => void;
+ accountNumber: number;
+ currentAccountNumber: number;
+ /** Spark USDB allocated position in USD (token balance not in native pocket sum). BTC yield is already in Spark native balance. */
+ sparkUsdbAllocatedUsd: number;
+}) => {
const availableNetworks = useAvailableNetworks();
const IconComponent = item.iconCollection === 'ion' ? Ionicons : Foundation;
const { accountBalance } = useAccountBalance(accountNumber, availableNetworks);
@@ -52,7 +51,11 @@ const ListItem = ({ item, onPress, accountNumber, currentAccountNumber }: { item
const active = accountNumber === currentAccountNumber;
- const usdBalance = accountBalance && exchangeRate ? formatFiatBalance(accountBalance, getDecimalsByNetwork(NETWORK_BITCOIN), exchangeRate) : '—';
+ const usdBalance = useMemo(() => {
+ if (!exchangeRate) return '—';
+ const btcUsd = accountBalance ? parseFloat(formatFiatBalance(accountBalance, getDecimalsByNetwork(NETWORK_BITCOIN), exchangeRate)) : 0;
+ return (btcUsd + sparkUsdbAllocatedUsd).toFixed(2);
+ }, [accountBalance, exchangeRate, sparkUsdbAllocatedUsd]);
return (
@@ -77,6 +80,37 @@ export default function PocketSwitch() {
const { network } = useContext(NetworkContext);
const { accountNumber: currentAccountNumber, setAccountNumber } = useContext(AccountNumberContext);
+ const availableNetworks = useAvailableNetworks();
+ const { exchangeRate } = useExchangeRate(NETWORK_BITCOIN, 'USD');
+
+ const { accountBalance: balance0 } = useAccountBalance(0, availableNetworks);
+ const { accountBalance: balance1 } = useAccountBalance(1, availableNetworks);
+ const { accountBalance: balance2 } = useAccountBalance(2, availableNetworks);
+ const { accountBalance: balance3 } = useAccountBalance(3, availableNetworks);
+ const { accountBalance: balance4 } = useAccountBalance(4, availableNetworks);
+
+ const earnMetrics0 = useSparkUsdbEarnMetrics(0, BackgroundExecutor);
+ const earnMetrics1 = useSparkUsdbEarnMetrics(1, BackgroundExecutor);
+ const earnMetrics2 = useSparkUsdbEarnMetrics(2, BackgroundExecutor);
+ const earnMetrics3 = useSparkUsdbEarnMetrics(3, BackgroundExecutor);
+ const earnMetrics4 = useSparkUsdbEarnMetrics(4, BackgroundExecutor);
+
+ const totalUsdDisplay = useMemo(() => {
+ if (!exchangeRate) return '—';
+ const balances = [balance0, balance1, balance2, balance3, balance4].slice(0, accountItems.length);
+ const btcSumSats = balances.reduce((sum, bal) => sum + (parseInt(bal, 10) || 0), 0);
+ const btcUsd = parseFloat(formatFiatBalance(String(btcSumSats), getDecimalsByNetwork(NETWORK_BITCOIN), exchangeRate));
+ const earnMetrics = [earnMetrics0, earnMetrics1, earnMetrics2, earnMetrics3, earnMetrics4].slice(0, accountItems.length);
+ /** USDB only — matches token not in native balance; BTC yield already in Spark sats above. */
+ const usdbEarnSum = earnMetrics.reduce((sum, m) => sum + m.allocatedUsd, 0);
+ return (btcUsd + usdbEarnSum).toFixed(2);
+ }, [exchangeRate, balance0, balance1, balance2, balance3, balance4, earnMetrics0, earnMetrics1, earnMetrics2, earnMetrics3, earnMetrics4]);
+
+ const sparkUsdbAllocatedByAccount = useMemo(
+ () => [earnMetrics0, earnMetrics1, earnMetrics2, earnMetrics3, earnMetrics4].slice(0, accountItems.length).map((m) => m.allocatedUsd),
+ [earnMetrics0, earnMetrics1, earnMetrics2, earnMetrics3, earnMetrics4]
+ );
+
const handleClose = () => {
router.back();
};
@@ -90,17 +124,21 @@ export default function PocketSwitch() {
- {/* Total Balance Section */}
-
+
- {/* Header */}
Pockets
- {/* Target Networks List */}
{accountItems.map((item, index) => (
- handleSelect(index)} />
+ handleSelect(index)}
+ />
))}
diff --git a/mobile/app/YieldList.tsx b/mobile/app/YieldList.tsx
index 9f58ec0c8..07a2c8ec5 100644
--- a/mobile/app/YieldList.tsx
+++ b/mobile/app/YieldList.tsx
@@ -1,6 +1,7 @@
import React, { useCallback, useContext, useMemo, useState } from 'react';
import { StyleSheet, TouchableOpacity, View } from 'react-native';
import { Image } from 'expo-image';
+import EarnBalanceSummary from '@/components/EarnBalanceSummary';
import RadialGradientScreen from '@/components/RadialGradientScreen';
import ScreenHeader from '@/components/navigation/ScreenHeader';
import SectionContainer from '@/components/SectionContainer';
@@ -11,6 +12,7 @@ import { BackgroundExecutor } from '@/src/modules/background-executor';
import { getNetworkImageAsset } from '@/utils/networkAssets';
import { AccountNumberContext } from '@shared/hooks/AccountNumberContext';
import { NetworkContext } from '@shared/hooks/NetworkContext';
+import { useSparkUsdbEarnMetrics } from '@shared/hooks/useSparkUsdbEarnMetrics';
import { useYieldDiscovery, YieldBearingCachedTokenInfo, YIELD_TOKEN_DEFINITIONS_BY_NETWORK } from '@shared/hooks/useYieldDiscovery';
import { getTokenInfo } from '@shared/models/token-list';
import { AssetId } from '@shared/types/asset';
@@ -38,6 +40,8 @@ export default function YieldListScreen() {
const { yieldList: citreaYield } = useYieldDiscovery(NETWORK_CITREA, accountNumber, BackgroundExecutor, LayerzStorage);
const { yieldList: sparkYield } = useYieldDiscovery(NETWORK_SPARK, accountNumber, BackgroundExecutor, LayerzStorage);
+ const { earnTotalUsd, rewards30dUsd, rewardsLifetimeUsd, isLoading: earnMetricsLoading } = useSparkUsdbEarnMetrics(accountNumber, BackgroundExecutor);
+
const allYields = useMemo(
() => [
...botanixYield.map((y): YieldWithNetwork => ({ ...y, network: NETWORK_BOTANIX })),
@@ -84,6 +88,7 @@ export default function YieldListScreen() {
+
{allYields.map((yieldToken) => (
0 ? '+' : '';
+ const abs = Math.abs(amount);
+ const formatted = new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2,
+ }).format(abs);
+ return amount < 0 ? `-${formatted}` : `${sign}${formatted}`;
+}
+
+export interface EarnBalanceSummaryProps {
+ earnTotalUsd: number;
+ rewards30dUsd: number;
+ rewardsLifetimeUsd: number;
+ isLoading: boolean;
+}
+
+/**
+ * Earn hero + two side-by-side reward cards (same panel style as SectionContainer body).
+ */
+const EarnBalanceSummary: React.FC = ({ earnTotalUsd, rewards30dUsd, rewardsLifetimeUsd, isLoading }) => {
+ return (
+
+ Earn balance
+ {isLoading ? (
+
+ ) : (
+
+ {formatEarnUsd(earnTotalUsd)}
+
+ )}
+
+
+
+ Last 30d
+ {isLoading ? : {formatEarnUsd(rewards30dUsd, { showSign: true })}}
+
+
+ Lifetime
+ {isLoading ? : {formatEarnUsd(rewardsLifetimeUsd, { showSign: true })}}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ marginBottom: 8,
+ },
+ heroLabel: {
+ fontSize: 15,
+ lineHeight: 20,
+ fontWeight: '500',
+ color: LABEL_GRAY,
+ marginBottom: 6,
+ },
+ heroAmount: {
+ fontSize: 40,
+ lineHeight: 48,
+ color: '#FFFFFF',
+ letterSpacing: -0.5,
+ marginBottom: 20,
+ },
+ loader: {
+ alignSelf: 'flex-start',
+ marginBottom: 20,
+ },
+ rewardsRow: {
+ flexDirection: 'row',
+ gap: 12,
+ alignItems: 'stretch',
+ },
+ /** Matches SectionContainer inner `container` (Allocated / Available panels). */
+ rewardCard: {
+ flex: 1,
+ backgroundColor: overlayBackgroundSections,
+ borderRadius: BORDER_RADIUS,
+ borderWidth: 1,
+ borderColor: SECTION_BORDER,
+ overflow: 'hidden',
+ paddingHorizontal: 14,
+ paddingVertical: 12,
+ minHeight: 72,
+ justifyContent: 'center',
+ },
+ rewardLabel: {
+ fontSize: 13,
+ fontWeight: '500',
+ color: LABEL_GRAY,
+ marginBottom: 6,
+ },
+ rewardValue: {
+ fontSize: 17,
+ fontWeight: '700',
+ color: POSITIVE_GREEN,
+ },
+});
+
+export default EarnBalanceSummary;
diff --git a/mobile/components/RadialGradientScreen.tsx b/mobile/components/RadialGradientScreen.tsx
index 42cdfd7e0..bb657ea43 100644
--- a/mobile/components/RadialGradientScreen.tsx
+++ b/mobile/components/RadialGradientScreen.tsx
@@ -3,19 +3,21 @@ import { NativeScrollEvent, NativeSyntheticEvent, RefreshControl, StyleSheet, Vi
import Animated from 'react-native-reanimated';
import { SafeAreaView } from 'react-native-safe-area-context';
-import { getNetworkPrimaryColor } from '@shared/constants/Colors';
+import { getNetworkPrimaryColor, globalDarkBackground } from '@shared/constants/Colors';
import { RadialGradient } from './RadialGradient';
interface RadialGradientScreenProps {
children: React.ReactNode;
style?: ViewStyle;
network?: string;
+ /** Solid black background; no network gradient (e.g. Earn tab). */
+ plainBlack?: boolean;
scroll?: boolean;
onScroll?: (event: NativeSyntheticEvent) => void;
refreshControl?: React.ReactElement>;
}
-const RadialGradientScreen: React.FC = ({ children, style, network = 'base', scroll = false, onScroll, refreshControl }) => {
+const RadialGradientScreen: React.FC = ({ children, style, network = 'base', plainBlack = false, scroll = false, onScroll, refreshControl }) => {
const primaryColor = getNetworkPrimaryColor(network);
const colorList = [
{ offset: '0%', color: primaryColor, opacity: '1' },
@@ -24,9 +26,13 @@ const RadialGradientScreen: React.FC = ({ children, s
return (
-
-
-
+ {plainBlack ? (
+
+ ) : (
+
+
+
+ )}
{scroll ? (
{
+ const txs = transactions ?? [];
+ const now = Math.floor(Date.now() / 1000);
+ const since30d = now - 30 * 24 * 60 * 60;
+ const lifetimeSats = sumFlashnetYieldBtcSats(txs);
+ const sats30d = sumFlashnetYieldBtcSats(txs, undefined, { sinceTimestamp: since30d });
+ const btcRate = btcUsdRate ?? 0;
+ const usdbRate = tokenExchangeRate ?? 0;
+
+ const allocatedUsdBn = balance !== undefined && usdbRate > 0 ? new BigNumber(formatFiatBalance(balance, USDB_DECIMALS, usdbRate)) : new BigNumber(0);
+
+ const rewardsLifetimeUsd = satsToUsd(lifetimeSats, btcRate);
+ const rewards30dUsd = satsToUsd(sats30d, btcRate);
+ const earnTotalUsd = allocatedUsdBn.plus(rewardsLifetimeUsd);
+
+ return {
+ allocatedUsd: allocatedUsdBn.toNumber(),
+ rewards30dUsd: rewards30dUsd.toNumber(),
+ rewardsLifetimeUsd: rewardsLifetimeUsd.toNumber(),
+ earnTotalUsd: earnTotalUsd.toNumber(),
+ };
+ }, [transactions, balance, btcUsdRate, tokenExchangeRate]);
+
+ return {
+ ...metrics,
+ isLoading: txsLoading || balanceLoading || usdbRateLoading || btcRateLoading,
+ error: txsError ?? undefined,
+ };
+}
diff --git a/shared/modules/flashnet-usdb-yield.ts b/shared/modules/flashnet-usdb-yield.ts
new file mode 100644
index 000000000..ec3360607
--- /dev/null
+++ b/shared/modules/flashnet-usdb-yield.ts
@@ -0,0 +1,39 @@
+import BigNumber from 'bignumber.js';
+
+import { FLASHNET_USDB_YIELD_SENDER_SPARK_ADDRESS } from '../constants/flashnet';
+import { CommonTransaction } from '../types/common-transaction';
+import { NETWORK_SPARK } from '../types/networks';
+
+export function normalizeSparkAddressForCompare(address: string | undefined): string {
+ return (address ?? '').trim().toLowerCase();
+}
+
+/**
+ * True for Spark BTC transfer receives from the Flashnet yield payout address (not token rows).
+ */
+export function isFlashnetYieldBtcReceive(tx: CommonTransaction, yieldSenderNormalized: string): boolean {
+ if (tx.network !== NETWORK_SPARK) return false;
+ if (tx.direction !== 'receive') return false;
+ if (tx.tokenTransfers && tx.tokenTransfers.length > 0) return false;
+ if (tx.amount === undefined || tx.amount <= 0) return false;
+ return normalizeSparkAddressForCompare(tx.counterparty) === yieldSenderNormalized;
+}
+
+/**
+ * Sats received from Flashnet yield (Spark `getTransfers` uses satoshi amounts on `amount`).
+ */
+export function sumFlashnetYieldBtcSats(transactions: CommonTransaction[], yieldSenderAddress: string = FLASHNET_USDB_YIELD_SENDER_SPARK_ADDRESS, options?: { sinceTimestamp?: number }): BigNumber {
+ const normalized = normalizeSparkAddressForCompare(yieldSenderAddress);
+ let sum = new BigNumber(0);
+ for (const tx of transactions) {
+ if (!isFlashnetYieldBtcReceive(tx, normalized)) continue;
+ if (options?.sinceTimestamp !== undefined && tx.timestamp < options.sinceTimestamp) continue;
+ sum = sum.plus(new BigNumber(tx.amount!).integerValue(BigNumber.ROUND_FLOOR));
+ }
+ return sum;
+}
+
+export function satsToUsd(sats: BigNumber | string | number, btcUsdRate: number): BigNumber {
+ if (btcUsdRate <= 0) return new BigNumber(0);
+ return new BigNumber(sats.toString()).dividedBy(1e8).multipliedBy(btcUsdRate);
+}
diff --git a/shared/tests/unit-vi/flashnet-usdb-yield.test.ts b/shared/tests/unit-vi/flashnet-usdb-yield.test.ts
new file mode 100644
index 000000000..09a48608d
--- /dev/null
+++ b/shared/tests/unit-vi/flashnet-usdb-yield.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest';
+
+import { FLASHNET_USDB_YIELD_SENDER_SPARK_ADDRESS } from '../../constants/flashnet';
+import { isFlashnetYieldBtcReceive, normalizeSparkAddressForCompare, satsToUsd, sumFlashnetYieldBtcSats } from '../../modules/flashnet-usdb-yield';
+import { CommonTransaction } from '../../types/common-transaction';
+import { NETWORK_SPARK } from '../../types/networks';
+
+const YIELD_SENDER = FLASHNET_USDB_YIELD_SENDER_SPARK_ADDRESS;
+const YIELD_NORM = normalizeSparkAddressForCompare(YIELD_SENDER);
+
+function btcReceive(sats: number, counterparty: string, timestamp: number): CommonTransaction {
+ return {
+ network: NETWORK_SPARK,
+ txid: `tx-${timestamp}-${sats}`,
+ timestamp,
+ direction: 'receive',
+ amount: sats,
+ counterparty,
+ status: 'confirmed',
+ };
+}
+
+describe('flashnet-usdb-yield', () => {
+ it('normalizes Spark addresses for comparison', () => {
+ expect(normalizeSparkAddressForCompare(' Spark1ABC ')).toBe('spark1abc');
+ });
+
+ it('isFlashnetYieldBtcReceive matches yield sender BTC receive only', () => {
+ const ok = btcReceive(68, YIELD_SENDER, 1_700_000_000);
+ expect(isFlashnetYieldBtcReceive(ok, YIELD_NORM)).toBe(true);
+
+ const wrongSender = btcReceive(68, 'spark1other000000000000000000000000000000000000000000000000000', 1_700_000_000);
+ expect(isFlashnetYieldBtcReceive(wrongSender, YIELD_NORM)).toBe(false);
+
+ const tokenRow: CommonTransaction = {
+ ...ok,
+ amount: undefined,
+ tokenTransfers: [{ tokenId: 'btkn1x', amount: 1, decimals: 6, symbol: 'USDB' }],
+ };
+ expect(isFlashnetYieldBtcReceive(tokenRow, YIELD_NORM)).toBe(false);
+
+ const send = { ...ok, direction: 'send' as const };
+ expect(isFlashnetYieldBtcReceive(send, YIELD_NORM)).toBe(false);
+ });
+
+ it('sumFlashnetYieldBtcSats sums lifetime and respects sinceTimestamp', () => {
+ const t0 = 1_700_000_000;
+ const txs: CommonTransaction[] = [
+ btcReceive(100, YIELD_SENDER, t0),
+ btcReceive(200, YIELD_SENDER, t0 + 40 * 24 * 60 * 60),
+ btcReceive(999, 'spark1other000000000000000000000000000000000000000000000000000', t0),
+ ];
+ expect(sumFlashnetYieldBtcSats(txs).toFixed(0)).toBe('300');
+ const cutoff = t0 + 35 * 24 * 60 * 60;
+ expect(sumFlashnetYieldBtcSats(txs, undefined, { sinceTimestamp: cutoff }).toFixed(0)).toBe('200');
+ });
+
+ it('satsToUsd converts using BTC price', () => {
+ const usd = satsToUsd(100_000_000, 50_000);
+ expect(usd.toFixed(2)).toBe('50000.00');
+ expect(satsToUsd(68, 72_317).toFixed(4)).toBe('0.0492');
+ });
+});