-
Notifications
You must be signed in to change notification settings - Fork 4
feat: earn balance and rewards #683
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}> | ||
|
|
@@ -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)); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(); | ||
| }; | ||
|
|
@@ -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> | ||
|
|
||
| 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; |
| 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; |
There was a problem hiding this comment.
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. IfformatFiatBalanceever returns12,345.67,parseFloatgives you12and you quietly undercount. Do the arithmetic in sats/BigNumber then format at the end.