From f02962245e9fb6c9d833d8d2e74f07bf3c9ca6d5 Mon Sep 17 00:00:00 2001 From: li0nd3v Date: Fri, 10 Apr 2026 15:56:52 +0200 Subject: [PATCH] feat: earn balance and rewards --- mobile/app/PocketSwitch.tsx | 90 ++++++++++---- mobile/app/YieldList.tsx | 5 + mobile/components/EarnBalanceSummary.tsx | 113 ++++++++++++++++++ mobile/components/RadialGradientScreen.tsx | 16 ++- shared/constants/flashnet.ts | 7 ++ shared/hooks/useSparkUsdbEarnMetrics.ts | 64 ++++++++++ shared/modules/flashnet-usdb-yield.ts | 39 ++++++ .../tests/unit-vi/flashnet-usdb-yield.test.ts | 63 ++++++++++ 8 files changed, 366 insertions(+), 31 deletions(-) create mode 100644 mobile/components/EarnBalanceSummary.tsx create mode 100644 shared/constants/flashnet.ts create mode 100644 shared/hooks/useSparkUsdbEarnMetrics.ts create mode 100644 shared/modules/flashnet-usdb-yield.ts create mode 100644 shared/tests/unit-vi/flashnet-usdb-yield.test.ts 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'); + }); +});