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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 64 additions & 26 deletions mobile/app/PocketSwitch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,52 +7,55 @@ 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 (
<View style={styles.totalBalanceSection}>
<ThemedText style={styles.totalBalanceLabel}>Total balance</ThemedText>
<ThemedText type="sfProRounded" style={styles.totalBalanceAmount}>
${totalUsd}
${totalUsdDisplay}
</ThemedText>
</View>
);
};
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);
const { exchangeRate } = useExchangeRate(NETWORK_BITCOIN, 'USD');

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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line 56: parseFloat(formatFiatBalance(...)) is a trap. If formatFiatBalance ever returns 12,345.67, parseFloat gives you 12 and you quietly undercount. Do the arithmetic in sats/BigNumber then format at the end.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don’t parseFloat(formatFiatBalance(...)). If that formatter ever emits commas or a $ prefix, parseFloat gives you clown math ("1,234.56" -> 1). Use a numeric conversion path (sats->usd) or a non-localized formatter.

return (btcUsd + sparkUsdbAllocatedUsd).toFixed(2);
}, [accountBalance, exchangeRate, sparkUsdbAllocatedUsd]);

return (
<Pressable style={[styles.item, active && styles.activeItem]} onPress={onPress} scaleOnPress={0.97}>
Expand All @@ -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));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line 102: same issue here. You’re parsing a display string back into a number. Stop the boomerang: compute USD from sats * rate, then format once.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same bug here. formatFiatBalance is for display, not arithmetic. Do the sum in BigNumber/number first, then format once at the end.

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();
};
Expand All @@ -90,17 +124,21 @@ export default function PocketSwitch() {
<DetachedSheet variant={network} onClose={handleClose}>
<SafeAreaView style={styles.safeArea} edges={Platform.OS === 'ios' ? ['left', 'right', 'bottom'] : ['left', 'right']}>
<View style={styles.container}>
{/* Total Balance Section */}
<TotalBalanceSection />
<TotalBalanceSection totalUsdDisplay={totalUsdDisplay} />

{/* Header */}
<View style={styles.header}>
<ThemedText style={styles.title}>Pockets</ThemedText>
</View>
{/* Target Networks List */}
<View style={styles.listContainer}>
{accountItems.map((item, index) => (
<ListItem key={index} accountNumber={index} currentAccountNumber={currentAccountNumber} item={item} onPress={() => handleSelect(index)} />
<ListItem
key={index}
accountNumber={index}
currentAccountNumber={currentAccountNumber}
item={item}
sparkUsdbAllocatedUsd={sparkUsdbAllocatedByAccount[index] ?? 0}
onPress={() => handleSelect(index)}
/>
))}
</View>
</View>
Expand Down
5 changes: 5 additions & 0 deletions mobile/app/YieldList.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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<YieldWithNetwork[]>(
() => [
...botanixYield.map((y): YieldWithNetwork => ({ ...y, network: NETWORK_BOTANIX })),
Expand Down Expand Up @@ -84,6 +88,7 @@ export default function YieldListScreen() {
<RadialGradientScreen network={network} scroll={true}>
<ScreenHeader title="Earn" />
<View style={styles.list}>
<EarnBalanceSummary earnTotalUsd={earnTotalUsd} rewards30dUsd={rewards30dUsd} rewardsLifetimeUsd={rewardsLifetimeUsd} isLoading={earnMetricsLoading} />
<SectionContainer title="Allocated" contentStyle={styles.sectionRows}>
{allYields.map((yieldToken) => (
<YieldRow
Expand Down
113 changes: 113 additions & 0 deletions mobile/components/EarnBalanceSummary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React from 'react';
import { ActivityIndicator, StyleSheet, View } from 'react-native';

import { ThemedText } from '@/components/ThemedText';
import { overlayBackgroundSections } from '@shared/constants/Colors';

const LABEL_GRAY = 'rgba(255, 255, 255, 0.6)';
const POSITIVE_GREEN = '#FFFFFF';
const SECTION_BORDER = 'rgba(255, 255, 255, 0.1)';
const BORDER_RADIUS = 12;

export function formatEarnUsd(amount: number, options?: { showSign?: boolean }): string {
const sign = options?.showSign && amount > 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<EarnBalanceSummaryProps> = ({ earnTotalUsd, rewards30dUsd, rewardsLifetimeUsd, isLoading }) => {
return (
<View style={styles.container}>
<ThemedText style={styles.heroLabel}>Earn balance</ThemedText>
{isLoading ? (
<ActivityIndicator size="small" color="#ffffff" style={styles.loader} />
) : (
<ThemedText type="sfProRounded" style={styles.heroAmount}>
{formatEarnUsd(earnTotalUsd)}
</ThemedText>
)}

<View style={styles.rewardsRow}>
<View style={styles.rewardCard}>
<ThemedText style={styles.rewardLabel}>Last 30d</ThemedText>
{isLoading ? <ActivityIndicator size="small" color={POSITIVE_GREEN} /> : <ThemedText style={styles.rewardValue}>{formatEarnUsd(rewards30dUsd, { showSign: true })}</ThemedText>}
</View>
<View style={styles.rewardCard}>
<ThemedText style={styles.rewardLabel}>Lifetime</ThemedText>
{isLoading ? <ActivityIndicator size="small" color={POSITIVE_GREEN} /> : <ThemedText style={styles.rewardValue}>{formatEarnUsd(rewardsLifetimeUsd, { showSign: true })}</ThemedText>}
</View>
</View>
</View>
);
};

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;
16 changes: 11 additions & 5 deletions mobile/components/RadialGradientScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<NativeScrollEvent>) => void;
refreshControl?: React.ReactElement<React.ComponentProps<typeof RefreshControl>>;
}

const RadialGradientScreen: React.FC<RadialGradientScreenProps> = ({ children, style, network = 'base', scroll = false, onScroll, refreshControl }) => {
const RadialGradientScreen: React.FC<RadialGradientScreenProps> = ({ children, style, network = 'base', plainBlack = false, scroll = false, onScroll, refreshControl }) => {
const primaryColor = getNetworkPrimaryColor(network);
const colorList = [
{ offset: '0%', color: primaryColor, opacity: '1' },
Expand All @@ -24,9 +26,13 @@ const RadialGradientScreen: React.FC<RadialGradientScreenProps> = ({ children, s

return (
<View style={styles.container}>
<View style={styles.gradientWrapper}>
<RadialGradient colorList={colorList} x="50%" y="-20.71%" rx="109.91%" ry="76.76%" />
</View>
{plainBlack ? (
<View style={[styles.gradientWrapper, { backgroundColor: globalDarkBackground }]} />
) : (
<View style={styles.gradientWrapper}>
<RadialGradient colorList={colorList} x="50%" y="-20.71%" rx="109.91%" ry="76.76%" />
</View>
)}
{scroll ? (
<Animated.ScrollView
style={styles.scrollView}
Expand Down
7 changes: 7 additions & 0 deletions shared/constants/flashnet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Flashnet USDB earn pays yield in BTC via Spark transfers from this sender address.
* If Flashnet rotates this address, update here (or move to remote config).
*
* @see https://docs.flashnet.xyz/usdb/overview
*/
export const FLASHNET_USDB_YIELD_SENDER_SPARK_ADDRESS = 'spark1pgss9f3qsrjk62e72cgw90v6cw3wzvg960lserta2hscl96zyte9r8nv22xmls' as const;
Loading
Loading