From bb544af696931497b06ef68dfbe1a9b63219299e Mon Sep 17 00:00:00 2001 From: Gancho Radkov Date: Wed, 5 Nov 2025 10:08:17 +0200 Subject: [PATCH 1/7] feat: displays providers --- .../wallets/react-wallet-v2/next.config.js | 3 + .../public/icons/earn-icon.svg | 6 + .../src/components/Earn/AmountInput.tsx | 109 +++++ .../src/components/Earn/PositionCard.tsx | 136 ++++++ .../src/components/Earn/ProtocolCard.tsx | 118 +++++ .../src/components/Navigation.tsx | 4 + .../src/data/EarnProtocolsData.ts | 176 ++++++++ .../react-wallet-v2/src/hooks/useEarnData.ts | 93 ++++ .../react-wallet-v2/src/lib/AaveLib.ts | 257 +++++++++++ .../react-wallet-v2/src/lib/SparkLib.ts | 239 ++++++++++ .../react-wallet-v2/src/pages/earn.tsx | 381 ++++++++++++++++ .../react-wallet-v2/src/store/EarnStore.ts | 157 +++++++ .../wallets/react-wallet-v2/src/types/earn.ts | 100 +++++ .../react-wallet-v2/src/utils/EarnService.ts | 414 ++++++++++++++++++ 14 files changed, 2193 insertions(+) create mode 100644 advanced/wallets/react-wallet-v2/public/icons/earn-icon.svg create mode 100644 advanced/wallets/react-wallet-v2/src/components/Earn/AmountInput.tsx create mode 100644 advanced/wallets/react-wallet-v2/src/components/Earn/PositionCard.tsx create mode 100644 advanced/wallets/react-wallet-v2/src/components/Earn/ProtocolCard.tsx create mode 100644 advanced/wallets/react-wallet-v2/src/data/EarnProtocolsData.ts create mode 100644 advanced/wallets/react-wallet-v2/src/hooks/useEarnData.ts create mode 100644 advanced/wallets/react-wallet-v2/src/lib/AaveLib.ts create mode 100644 advanced/wallets/react-wallet-v2/src/lib/SparkLib.ts create mode 100644 advanced/wallets/react-wallet-v2/src/pages/earn.tsx create mode 100644 advanced/wallets/react-wallet-v2/src/store/EarnStore.ts create mode 100644 advanced/wallets/react-wallet-v2/src/types/earn.ts create mode 100644 advanced/wallets/react-wallet-v2/src/utils/EarnService.ts diff --git a/advanced/wallets/react-wallet-v2/next.config.js b/advanced/wallets/react-wallet-v2/next.config.js index ebacdf340..21b4bf7df 100644 --- a/advanced/wallets/react-wallet-v2/next.config.js +++ b/advanced/wallets/react-wallet-v2/next.config.js @@ -1,5 +1,8 @@ module.exports = { reactStrictMode: true, + images: { + domains: ['cryptologos.cc', 'avatars.githubusercontent.com'] + }, webpack(config) { config.resolve.fallback = { ...config.resolve.fallback, diff --git a/advanced/wallets/react-wallet-v2/public/icons/earn-icon.svg b/advanced/wallets/react-wallet-v2/public/icons/earn-icon.svg new file mode 100644 index 000000000..efc726e82 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/public/icons/earn-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/advanced/wallets/react-wallet-v2/src/components/Earn/AmountInput.tsx b/advanced/wallets/react-wallet-v2/src/components/Earn/AmountInput.tsx new file mode 100644 index 000000000..e56ad2336 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/components/Earn/AmountInput.tsx @@ -0,0 +1,109 @@ +import { Input, Button, Row, Text, styled } from '@nextui-org/react' +import { ChangeEvent, useMemo } from 'react' + +const StyledText = styled(Text, { + fontWeight: 400 +} as any) + +interface AmountInputProps { + value: string + onChange: (value: string) => void + balance: string + tokenSymbol: string + disabled?: boolean + label?: string + placeholder?: string +} + +export default function AmountInput({ + value, + onChange, + balance, + tokenSymbol, + disabled = false, + label = 'Amount to Deposit', + placeholder = '0.00' +}: AmountInputProps) { + const handleChange = (e: ChangeEvent) => { + const inputValue = e.target.value + // Allow only numbers and decimal point + if (inputValue === '' || /^\d*\.?\d*$/.test(inputValue)) { + onChange(inputValue) + } + } + + const handleMaxClick = () => { + onChange(balance) + } + + const isMaxDisabled = useMemo(() => { + return disabled || !balance || balance === '0' + }, [disabled, balance]) + + const formattedBalance = useMemo(() => { + if (!balance || balance === '0') return '0' + const num = parseFloat(balance) + if (isNaN(num)) return '0' + return num.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 6 + }) + }, [balance]) + + return ( +
+ + {label} + + Balance: {formattedBalance} {tokenSymbol} + + + +
+ +
+ {tokenSymbol} + +
+
+
+ ) +} diff --git a/advanced/wallets/react-wallet-v2/src/components/Earn/PositionCard.tsx b/advanced/wallets/react-wallet-v2/src/components/Earn/PositionCard.tsx new file mode 100644 index 000000000..d5ef7e99e --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/components/Earn/PositionCard.tsx @@ -0,0 +1,136 @@ +import { Card, Row, Col, Text, Button, styled } from '@nextui-org/react' +import { UserPosition } from '@/types/earn' +import { PROTOCOLS } from '@/data/EarnProtocolsData' +import Image from 'next/image' +import { useMemo } from 'react' + +// Simple Badge component since NextUI v1 doesn't have Badge +const Badge = styled('span', { + display: 'inline-block', + padding: '4px 12px', + borderRadius: '12px', + fontSize: '12px', + fontWeight: '600', + backgroundColor: 'rgba(23, 201, 100, 0.15)', + color: '#17c964' +} as any) + +const StyledCard = styled(Card, { + padding: '$6', + marginBottom: '$6' +} as any) + +const StyledText = styled(Text, { + fontWeight: 400 +} as any) + +interface PositionCardProps { + position: UserPosition + onWithdraw: (position: UserPosition) => void +} + +export default function PositionCard({ position, onWithdraw }: PositionCardProps) { + const protocol = PROTOCOLS[position.protocol.toUpperCase()] + + const formattedDepositDate = useMemo(() => { + const date = new Date(position.depositedAt) + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }) + }, [position.depositedAt]) + + const durationDays = useMemo(() => { + const now = Date.now() + const diff = now - position.depositedAt + return Math.floor(diff / (1000 * 60 * 60 * 24)) + }, [position.depositedAt]) + + return ( + + + + {protocol?.logo && ( + {protocol.name} + )} +
+ + {protocol?.displayName || position.protocol} + + + {position.token} • Deposited {formattedDepositDate} ({durationDays} days) + +
+ + + + {position.apy.toFixed(2)}% APY + +
+ + + + + Principal + + + {parseFloat(position.principal).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 6 + })}{' '} + {position.token} + + + ${position.principalUSD} + + + + + + Rewards Earned + + + + + {parseFloat(position.rewards).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 6 + })}{' '} + {position.token} + + + ${position.rewardsUSD} + + + + + + Total Value + + + {parseFloat(position.total).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 6 + })}{' '} + {position.token} + + + ${position.totalUSD} + + + + + + + +
+ ) +} diff --git a/advanced/wallets/react-wallet-v2/src/components/Earn/ProtocolCard.tsx b/advanced/wallets/react-wallet-v2/src/components/Earn/ProtocolCard.tsx new file mode 100644 index 000000000..1caf40c1e --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/components/Earn/ProtocolCard.tsx @@ -0,0 +1,118 @@ +import { Card, Row, Col, Text, styled } from '@nextui-org/react' +import { ProtocolConfig } from '@/types/earn' +import Image from 'next/image' + +// Minimal Badge component +const Badge = styled('span', { + display: 'inline-flex', + padding: '2px 8px', + borderRadius: '4px', + fontSize: '11px', + fontWeight: '500', + variants: { + color: { + success: { + backgroundColor: 'rgba(34, 197, 94, 0.1)', + color: 'rgb(34, 197, 94)' + }, + warning: { + backgroundColor: 'rgba(251, 191, 36, 0.1)', + color: 'rgb(251, 191, 36)' + }, + error: { + backgroundColor: 'rgba(239, 68, 68, 0.1)', + color: 'rgb(239, 68, 68)' + }, + primary: { + backgroundColor: 'rgba(99, 102, 241, 0.1)', + color: 'rgb(99, 102, 241)' + }, + secondary: { + backgroundColor: 'rgba(168, 85, 247, 0.1)', + color: 'rgb(168, 85, 247)' + }, + default: { + backgroundColor: 'rgba(156, 163, 175, 0.1)', + color: 'rgb(156, 163, 175)' + } + } + } +} as any) + +const StyledCard = styled(Card, { + padding: '0px', + marginBottom: '12px', + cursor: 'pointer', + transition: 'all 0.15s ease', + border: '1px solid rgba(255, 255, 255, 0.1)', + '&:hover': { + borderColor: 'rgba(99, 102, 241, 0.5)', + backgroundColor: 'rgba(99, 102, 241, 0.02)' + } +} as any) + +interface ProtocolCardProps { + config: ProtocolConfig + selected?: boolean + onSelect: (config: ProtocolConfig) => void +} + +export default function ProtocolCard({ config, selected, onSelect }: ProtocolCardProps) { + const getRiskColor = (risk: string) => { + switch (risk) { + case 'Low': + return 'success' + case 'Medium': + return 'warning' + case 'High': + return 'error' + default: + return 'default' + } + } + + return ( + onSelect(config)} + css={{ + borderColor: selected ? 'rgb(99, 102, 241)' : 'rgba(255, 255, 255, 0.1)', + backgroundColor: selected ? 'rgba(99, 102, 241, 0.05)' : 'transparent' + }} + > + {/* Protocol Header */} +
+
+ + {config.protocol.displayName} + + + {config.token.symbol} • {config.chainName} + +
+
+ + {config.apy.toFixed(2)}% APY + +
+
+ + {/* Details */} +
+
+
+ TVL: {config.tvl} +
+ Risk: {config.riskLevel} +
+
+
+ ) +} diff --git a/advanced/wallets/react-wallet-v2/src/components/Navigation.tsx b/advanced/wallets/react-wallet-v2/src/components/Navigation.tsx index 15c148d05..b99b9a20a 100644 --- a/advanced/wallets/react-wallet-v2/src/components/Navigation.tsx +++ b/advanced/wallets/react-wallet-v2/src/components/Navigation.tsx @@ -33,6 +33,10 @@ export default function Navigation() { pairings icon + + earn icon + + settings icon diff --git a/advanced/wallets/react-wallet-v2/src/data/EarnProtocolsData.ts b/advanced/wallets/react-wallet-v2/src/data/EarnProtocolsData.ts new file mode 100644 index 000000000..6f9301116 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/data/EarnProtocolsData.ts @@ -0,0 +1,176 @@ +import { Protocol, ProtocolConfig } from '@/types/earn' + +/** + * Protocol definitions + */ +export const PROTOCOLS: Record = { + AAVE: { + id: 'aave', + name: 'Aave V3', + displayName: 'Aave V3', + logo: 'https://cryptologos.cc/logos/aave-aave-logo.png', + description: 'Leading decentralized lending protocol' + }, + SPARK: { + id: 'spark', + name: 'Spark Protocol', + displayName: 'Spark Protocol', + logo: 'https://avatars.githubusercontent.com/u/113572553?s=200&v=4', + description: 'DeFi infrastructure by MakerDAO' + } +} + +/** + * Supported chains for Earn + */ +export const EARN_CHAINS = { + BASE: { + id: 8453, + name: 'Base', + rpc: 'https://mainnet.base.org' + }, + ETHEREUM: { + id: 1, + name: 'Ethereum', + rpc: 'https://eth.llamarpc.com' + }, + ARBITRUM: { + id: 42161, + name: 'Arbitrum', + rpc: 'https://arb1.arbitrum.io/rpc' + } +} + +/** + * USDC token configurations per chain + */ +export const USDC_TOKENS = { + [EARN_CHAINS.BASE.id]: { + symbol: 'USDC', + address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + decimals: 6, + logo: 'https://cryptologos.cc/logos/usd-coin-usdc-logo.png' + }, + [EARN_CHAINS.ETHEREUM.id]: { + symbol: 'USDC', + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + logo: 'https://cryptologos.cc/logos/usd-coin-usdc-logo.png' + }, + [EARN_CHAINS.ARBITRUM.id]: { + symbol: 'USDC', + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + decimals: 6, + logo: 'https://cryptologos.cc/logos/usd-coin-usdc-logo.png' + } +} + +/** + * Protocol configurations + * Note: These are mock APY values. Phase 2 will fetch real-time data + */ +export const PROTOCOL_CONFIGS: ProtocolConfig[] = [ + // Aave V3 on Base + { + protocol: PROTOCOLS.AAVE, + chainId: EARN_CHAINS.BASE.id, + chainName: EARN_CHAINS.BASE.name, + token: USDC_TOKENS[EARN_CHAINS.BASE.id], + contracts: { + pool: '0xA238Dd80C259a72e81d7e4664a9801593F98d1c5', + poolDataProvider: '0x2d8A3C5677189723C4cB8873CfC9C8976FDF38Ac', + aToken: '0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB' + }, + apy: 4.35, + tvl: '$89.2M', + riskLevel: 'Low', + features: { + autoCompound: true, + lockupPeriod: false, + instantWithdraw: true + } + }, + // Spark Protocol on Base + { + protocol: PROTOCOLS.SPARK, + chainId: EARN_CHAINS.BASE.id, + chainName: EARN_CHAINS.BASE.name, + token: USDC_TOKENS[EARN_CHAINS.BASE.id], + contracts: { + pool: '0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB', // Placeholder - update with actual Spark address + poolDataProvider: '0x2d8A3C5677189723C4cB8873CfC9C8976FDF38Ac' // Placeholder + }, + apy: 4.82, + tvl: '$125.4M', + riskLevel: 'Low', + features: { + autoCompound: true, + lockupPeriod: false, + instantWithdraw: true + } + }, + // Aave V3 on Ethereum + { + protocol: PROTOCOLS.AAVE, + chainId: EARN_CHAINS.ETHEREUM.id, + chainName: EARN_CHAINS.ETHEREUM.name, + token: USDC_TOKENS[EARN_CHAINS.ETHEREUM.id], + contracts: { + pool: '0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2', + poolDataProvider: '0x7B4EB56E7CD4b454BA8ff71E4518426369a138a3', + aToken: '0x98C23E9d8f34FEFb1B7BD6a91B7FF122F4e16F5c' + }, + apy: 5.12, + tvl: '$1.2B', + riskLevel: 'Low', + features: { + autoCompound: true, + lockupPeriod: false, + instantWithdraw: true + } + }, + // Spark Protocol on Ethereum + { + protocol: PROTOCOLS.SPARK, + chainId: EARN_CHAINS.ETHEREUM.id, + chainName: EARN_CHAINS.ETHEREUM.name, + token: USDC_TOKENS[EARN_CHAINS.ETHEREUM.id], + contracts: { + pool: '0xC13e21B648A5Ee794902342038FF3aDAB66BE987', + poolDataProvider: '0xFc21d6d146E6086B8359705C8b28512a983db0cb' + }, + apy: 5.45, + tvl: '$892.5M', + riskLevel: 'Low', + features: { + autoCompound: true, + lockupPeriod: false, + instantWithdraw: true + } + } +] + +/** + * Get protocol configs for a specific chain + */ +export function getProtocolsByChain(chainId: number): ProtocolConfig[] { + return PROTOCOL_CONFIGS.filter(config => config.chainId === chainId) +} + +/** + * Get specific protocol config + */ +export function getProtocolConfig(protocolId: string, chainId: number): ProtocolConfig | undefined { + return PROTOCOL_CONFIGS.find( + config => config.protocol.id === protocolId && config.chainId === chainId + ) +} + +/** + * Get best APY protocol for a chain + */ +export function getBestAPYProtocol(chainId: number): ProtocolConfig | undefined { + const protocols = getProtocolsByChain(chainId) + if (protocols.length === 0) return undefined + return protocols.reduce((best, current) => (current.apy > best.apy ? current : best)) +} diff --git a/advanced/wallets/react-wallet-v2/src/hooks/useEarnData.ts b/advanced/wallets/react-wallet-v2/src/hooks/useEarnData.ts new file mode 100644 index 000000000..4ff144838 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/hooks/useEarnData.ts @@ -0,0 +1,93 @@ +/** + * Hook to manage Earn data fetching and synchronization + */ + +import { useEffect, useCallback } from 'react' +import { useSnapshot } from 'valtio' +import EarnStore from '@/store/EarnStore' +import SettingsStore from '@/store/SettingsStore' +import { fetchAllProtocolAPYs, getAllUserPositions, getUserTokenBalance } from '@/utils/EarnService' +import { PROTOCOL_CONFIGS } from '@/data/EarnProtocolsData' + +export function useEarnData() { + const { selectedChainId, selectedProtocol } = useSnapshot(EarnStore.state) + const { eip155Address } = useSnapshot(SettingsStore.state) + + /** + * Fetch and update protocol APYs + */ + const refreshAPYs = useCallback(async () => { + if (!selectedChainId) return + + try { + const apyMap = await fetchAllProtocolAPYs(selectedChainId) + + // Update protocol configs with live APYs + // Note: This would need a more sophisticated state management approach + // For now, APYs are fetched but not persisted to the global state + console.log('Fetched APYs:', Array.from(apyMap.entries())) + } catch (error) { + console.error('Error refreshing APYs:', error) + } + }, [selectedChainId]) + + /** + * Fetch and update user positions + */ + const refreshPositions = useCallback(async () => { + if (!eip155Address) return + + try { + EarnStore.setPositionsLoading(true) + const positions = await getAllUserPositions(eip155Address, selectedChainId) + EarnStore.setPositions(positions) + } catch (error) { + console.error('Error refreshing positions:', error) + } finally { + EarnStore.setPositionsLoading(false) + } + }, [eip155Address, selectedChainId]) + + /** + * Fetch token balance for selected protocol + */ + const refreshBalance = useCallback(async () => { + if (!selectedProtocol || !eip155Address) return '0' + + try { + const balance = await getUserTokenBalance(selectedProtocol, eip155Address) + return balance + } catch (error) { + console.error('Error refreshing balance:', error) + return '0' + } + }, [selectedProtocol, eip155Address]) + + /** + * Refresh all data + */ + const refreshAllData = useCallback(async () => { + await Promise.all([refreshAPYs(), refreshPositions()]) + }, [refreshAPYs, refreshPositions]) + + // Auto-refresh on mount and when dependencies change + useEffect(() => { + refreshAllData() + + // Set up periodic refresh (every 30 seconds) + const interval = setInterval(() => { + refreshAllData() + }, 30000) + + return () => clearInterval(interval) + }, [refreshAllData]) + + return { + refreshAPYs, + refreshPositions, + refreshBalance, + refreshAllData + } +} + +export default useEarnData diff --git a/advanced/wallets/react-wallet-v2/src/lib/AaveLib.ts b/advanced/wallets/react-wallet-v2/src/lib/AaveLib.ts new file mode 100644 index 000000000..661f5e405 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/lib/AaveLib.ts @@ -0,0 +1,257 @@ +/** + * Aave V3 Protocol Integration Library + * Handles all interactions with Aave V3 contracts + */ + +import { ethers, providers, BigNumber } from 'ethers' + +// Aave V3 Pool ABI - minimal interface for deposit/withdraw/supply operations +const AAVE_POOL_ABI = [ + // Read methods + 'function getReserveData(address asset) external view returns (uint256 configuration, uint128 liquidityIndex, uint128 currentLiquidityRate, uint128 variableBorrowRate, uint128 stableBorrowRate, uint40 lastUpdateTimestamp, uint16 id, address aTokenAddress, address stableDebtTokenAddress, address variableDebtTokenAddress, address interestRateStrategyAddress, uint128 accruedToTreasury, uint128 unbacked, uint128 isolationModeTotalDebt)', + 'function getUserAccountData(address user) external view returns (uint256 totalCollateralBase, uint256 totalDebtBase, uint256 availableBorrowsBase, uint256 currentLiquidationThreshold, uint256 ltv, uint256 healthFactor)', + + // Write methods + 'function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external', + 'function withdraw(address asset, uint256 amount, address to) external returns (uint256)' +] + +// ERC20 ABI for token operations +const ERC20_ABI = [ + 'function balanceOf(address owner) view returns (uint256)', + 'function decimals() view returns (uint8)', + 'function allowance(address owner, address spender) view returns (uint256)', + 'function approve(address spender, uint256 amount) returns (bool)', + 'function symbol() view returns (string)', + 'function name() view returns (string)' +] + +// aToken ABI for reading deposited balance +const ATOKEN_ABI = [ + 'function balanceOf(address owner) view returns (uint256)', + 'function scaledBalanceOf(address user) view returns (uint256)' +] + +export interface AaveReserveData { + liquidityRate: string // APY in ray units (27 decimals) + aTokenAddress: string + availableLiquidity: string + totalSupply: string +} + +export interface AaveUserPosition { + supplied: string // Amount supplied (in token units) + suppliedUSD: string + apy: number +} + +export class AaveLib { + private provider: providers.Provider + private poolContract: ethers.Contract + private chainId: number + + constructor(providerUrl: string, poolAddress: string, chainId: number) { + this.provider = new providers.JsonRpcProvider(providerUrl) + this.poolContract = new ethers.Contract(poolAddress, AAVE_POOL_ABI, this.provider) + this.chainId = chainId + } + + /** + * Get reserve data for a specific asset (e.g., USDC) + */ + async getReserveData(tokenAddress: string): Promise { + try { + const data = await this.poolContract.getReserveData(tokenAddress) + + // currentLiquidityRate is in Ray units (1e27) + // Convert to APY percentage + const liquidityRate = data.currentLiquidityRate + const apy = this.rayToAPY(liquidityRate) + + return { + liquidityRate: liquidityRate.toString(), + aTokenAddress: data.aTokenAddress, + availableLiquidity: '0', // Would need additional call to get this + totalSupply: '0' // Would need additional call to get this + } + } catch (error) { + console.error('Error fetching Aave reserve data:', error) + throw error + } + } + + /** + * Get user's position (supplied amount and APY) + */ + async getUserPosition( + userAddress: string, + tokenAddress: string, + tokenDecimals: number = 6 + ): Promise { + try { + const reserveData = await this.getReserveData(tokenAddress) + const aTokenContract = new ethers.Contract( + reserveData.aTokenAddress, + ATOKEN_ABI, + this.provider + ) + + const balance = await aTokenContract.balanceOf(userAddress) + const supplied = ethers.utils.formatUnits(balance, tokenDecimals) + + // For mock, use 1 USDC = 1 USD + const suppliedUSD = supplied + + const apy = this.rayToAPY(BigNumber.from(reserveData.liquidityRate)) + + return { + supplied, + suppliedUSD, + apy + } + } catch (error) { + console.error('Error fetching Aave user position:', error) + throw error + } + } + + /** + * Get user's token balance + */ + async getTokenBalance(tokenAddress: string, userAddress: string): Promise { + try { + const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, this.provider) + const balance = await tokenContract.balanceOf(userAddress) + const decimals = await tokenContract.decimals() + return ethers.utils.formatUnits(balance, decimals) + } catch (error) { + console.error('Error fetching token balance:', error) + throw error + } + } + + /** + * Check if user has approved spending + */ + async checkAllowance( + tokenAddress: string, + userAddress: string, + spenderAddress: string + ): Promise { + try { + const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, this.provider) + const allowance = await tokenContract.allowance(userAddress, spenderAddress) + const decimals = await tokenContract.decimals() + return ethers.utils.formatUnits(allowance, decimals) + } catch (error) { + console.error('Error checking allowance:', error) + throw error + } + } + + /** + * Build approve transaction data + */ + buildApproveTransaction( + tokenAddress: string, + spenderAddress: string, + amount: string, + decimals: number = 6 + ) { + const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI) + const amountBN = ethers.utils.parseUnits(amount, decimals) + + return { + to: tokenAddress, + data: tokenContract.interface.encodeFunctionData('approve', [spenderAddress, amountBN]), + value: '0x0' + } + } + + /** + * Build supply (deposit) transaction data + */ + buildSupplyTransaction( + tokenAddress: string, + amount: string, + onBehalfOf: string, + decimals: number = 6 + ) { + const amountBN = ethers.utils.parseUnits(amount, decimals) + const referralCode = 0 // No referral + + return { + to: this.poolContract.address, + data: this.poolContract.interface.encodeFunctionData('supply', [ + tokenAddress, + amountBN, + onBehalfOf, + referralCode + ]), + value: '0x0' + } + } + + /** + * Build withdraw transaction data + */ + buildWithdrawTransaction(tokenAddress: string, amount: string, to: string, decimals: number = 6) { + // Use max uint256 to withdraw all + const amountBN = + amount === 'max' ? ethers.constants.MaxUint256 : ethers.utils.parseUnits(amount, decimals) + + return { + to: this.poolContract.address, + data: this.poolContract.interface.encodeFunctionData('withdraw', [ + tokenAddress, + amountBN, + to + ]), + value: '0x0' + } + } + + /** + * Convert Ray rate (1e27) to APY percentage + * Formula: APY = (1 + rate/1e27)^(365*24*60*60) - 1 + * Simplified for small rates: APY ≈ rate / 1e27 * seconds_per_year / 1e27 * 100 + */ + private rayToAPY(liquidityRate: BigNumber): number { + try { + const RAY = BigNumber.from(10).pow(27) + const SECONDS_PER_YEAR = 31536000 + + // liquidityRate is per second in Ray + // APY = ((1 + ratePerSecond)^secondsPerYear - 1) * 100 + // For small rates, approximate: APY ≈ ratePerSecond * secondsPerYear * 100 + + const ratePerSecond = liquidityRate.div(RAY) + const approximateAPY = (ratePerSecond.mul(SECONDS_PER_YEAR).toNumber() / 1e25) * 100 + + return approximateAPY + } catch (error) { + console.error('Error converting ray to APY:', error) + return 0 + } + } + + /** + * Estimate gas for a transaction + */ + async estimateGas(transaction: any, from: string): Promise { + try { + const gasLimit = await this.provider.estimateGas({ + ...transaction, + from + }) + const gasPrice = await this.provider.getGasPrice() + const gasCost = gasLimit.mul(gasPrice) + return ethers.utils.formatEther(gasCost) + } catch (error) { + console.error('Error estimating gas:', error) + return '0' + } + } +} + +export default AaveLib diff --git a/advanced/wallets/react-wallet-v2/src/lib/SparkLib.ts b/advanced/wallets/react-wallet-v2/src/lib/SparkLib.ts new file mode 100644 index 000000000..f275b17e0 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/lib/SparkLib.ts @@ -0,0 +1,239 @@ +/** + * Spark Protocol Integration Library + * Spark is a fork of Aave V3, so it uses the same interface + */ + +import { ethers, providers, BigNumber } from 'ethers' + +// Spark uses the same ABI as Aave V3 +const SPARK_POOL_ABI = [ + 'function getReserveData(address asset) external view returns (uint256 configuration, uint128 liquidityIndex, uint128 currentLiquidityRate, uint128 variableBorrowRate, uint128 stableBorrowRate, uint40 lastUpdateTimestamp, uint16 id, address aTokenAddress, address stableDebtTokenAddress, address variableDebtTokenAddress, address interestRateStrategyAddress, uint128 accruedToTreasury, uint128 unbacked, uint128 isolationModeTotalDebt)', + 'function getUserAccountData(address user) external view returns (uint256 totalCollateralBase, uint256 totalDebtBase, uint256 availableBorrowsBase, uint256 currentLiquidationThreshold, uint256 ltv, uint256 healthFactor)', + 'function supply(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external', + 'function withdraw(address asset, uint256 amount, address to) external returns (uint256)' +] + +const ERC20_ABI = [ + 'function balanceOf(address owner) view returns (uint256)', + 'function decimals() view returns (uint8)', + 'function allowance(address owner, address spender) view returns (uint256)', + 'function approve(address spender, uint256 amount) returns (bool)' +] + +const SPTOKEN_ABI = [ + 'function balanceOf(address owner) view returns (uint256)', + 'function scaledBalanceOf(address user) view returns (uint256)' +] + +export interface SparkReserveData { + liquidityRate: string + spTokenAddress: string // Spark's version of aToken + availableLiquidity: string + totalSupply: string +} + +export interface SparkUserPosition { + supplied: string + suppliedUSD: string + apy: number +} + +export class SparkLib { + private provider: providers.Provider + private poolContract: ethers.Contract + private chainId: number + + constructor(providerUrl: string, poolAddress: string, chainId: number) { + this.provider = new providers.JsonRpcProvider(providerUrl) + this.poolContract = new ethers.Contract(poolAddress, SPARK_POOL_ABI, this.provider) + this.chainId = chainId + } + + /** + * Get reserve data for a specific asset + */ + async getReserveData(tokenAddress: string): Promise { + try { + const data = await this.poolContract.getReserveData(tokenAddress) + + const liquidityRate = data.currentLiquidityRate + const apy = this.rayToAPY(liquidityRate) + + return { + liquidityRate: liquidityRate.toString(), + spTokenAddress: data.aTokenAddress, // Spark uses same structure + availableLiquidity: '0', + totalSupply: '0' + } + } catch (error) { + console.error('Error fetching Spark reserve data:', error) + throw error + } + } + + /** + * Get user's position + */ + async getUserPosition( + userAddress: string, + tokenAddress: string, + tokenDecimals: number = 6 + ): Promise { + try { + const reserveData = await this.getReserveData(tokenAddress) + const spTokenContract = new ethers.Contract( + reserveData.spTokenAddress, + SPTOKEN_ABI, + this.provider + ) + + const balance = await spTokenContract.balanceOf(userAddress) + const supplied = ethers.utils.formatUnits(balance, tokenDecimals) + const suppliedUSD = supplied + + const apy = this.rayToAPY(BigNumber.from(reserveData.liquidityRate)) + + return { + supplied, + suppliedUSD, + apy + } + } catch (error) { + console.error('Error fetching Spark user position:', error) + throw error + } + } + + /** + * Get token balance + */ + async getTokenBalance(tokenAddress: string, userAddress: string): Promise { + try { + const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, this.provider) + const balance = await tokenContract.balanceOf(userAddress) + const decimals = await tokenContract.decimals() + return ethers.utils.formatUnits(balance, decimals) + } catch (error) { + console.error('Error fetching token balance:', error) + throw error + } + } + + /** + * Check allowance + */ + async checkAllowance( + tokenAddress: string, + userAddress: string, + spenderAddress: string + ): Promise { + try { + const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI, this.provider) + const allowance = await tokenContract.allowance(userAddress, spenderAddress) + const decimals = await tokenContract.decimals() + return ethers.utils.formatUnits(allowance, decimals) + } catch (error) { + console.error('Error checking allowance:', error) + throw error + } + } + + /** + * Build approve transaction + */ + buildApproveTransaction( + tokenAddress: string, + spenderAddress: string, + amount: string, + decimals: number = 6 + ) { + const tokenContract = new ethers.Contract(tokenAddress, ERC20_ABI) + const amountBN = ethers.utils.parseUnits(amount, decimals) + + return { + to: tokenAddress, + data: tokenContract.interface.encodeFunctionData('approve', [spenderAddress, amountBN]), + value: '0x0' + } + } + + /** + * Build supply transaction + */ + buildSupplyTransaction( + tokenAddress: string, + amount: string, + onBehalfOf: string, + decimals: number = 6 + ) { + const amountBN = ethers.utils.parseUnits(amount, decimals) + const referralCode = 0 + + return { + to: this.poolContract.address, + data: this.poolContract.interface.encodeFunctionData('supply', [ + tokenAddress, + amountBN, + onBehalfOf, + referralCode + ]), + value: '0x0' + } + } + + /** + * Build withdraw transaction + */ + buildWithdrawTransaction(tokenAddress: string, amount: string, to: string, decimals: number = 6) { + const amountBN = + amount === 'max' ? ethers.constants.MaxUint256 : ethers.utils.parseUnits(amount, decimals) + + return { + to: this.poolContract.address, + data: this.poolContract.interface.encodeFunctionData('withdraw', [ + tokenAddress, + amountBN, + to + ]), + value: '0x0' + } + } + + /** + * Convert Ray rate to APY + */ + private rayToAPY(liquidityRate: BigNumber): number { + try { + const RAY = BigNumber.from(10).pow(27) + const SECONDS_PER_YEAR = 31536000 + + const ratePerSecond = liquidityRate.div(RAY) + const approximateAPY = (ratePerSecond.mul(SECONDS_PER_YEAR).toNumber() / 1e25) * 100 + + return approximateAPY + } catch (error) { + console.error('Error converting ray to APY:', error) + return 0 + } + } + + /** + * Estimate gas + */ + async estimateGas(transaction: any, from: string): Promise { + try { + const gasLimit = await this.provider.estimateGas({ + ...transaction, + from + }) + const gasPrice = await this.provider.getGasPrice() + const gasCost = gasLimit.mul(gasPrice) + return ethers.utils.formatEther(gasCost) + } catch (error) { + console.error('Error estimating gas:', error) + return '0' + } + } +} + +export default SparkLib diff --git a/advanced/wallets/react-wallet-v2/src/pages/earn.tsx b/advanced/wallets/react-wallet-v2/src/pages/earn.tsx new file mode 100644 index 000000000..a0c3d7fce --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/pages/earn.tsx @@ -0,0 +1,381 @@ +import { Fragment, useState, useMemo, useEffect } from 'react' +import { useSnapshot } from 'valtio' +import { + Container, + Row, + Col, + Text, + Button, + Card, + Loading, + Divider, + styled +} from '@nextui-org/react' +import PageHeader from '@/components/PageHeader' +import EarnStore from '@/store/EarnStore' +import SettingsStore from '@/store/SettingsStore' +import ProtocolCard from '@/components/Earn/ProtocolCard' +import AmountInput from '@/components/Earn/AmountInput' +import PositionCard from '@/components/Earn/PositionCard' +import { getProtocolsByChain } from '@/data/EarnProtocolsData' +import { ProtocolConfig, UserPosition } from '@/types/earn' +import { styledToast } from '@/utils/HelperUtil' +import useEarnData from '@/hooks/useEarnData' + +const StyledText = styled(Text, { + fontWeight: 400 +} as any) + +const StyledContainer = styled(Container, { + maxWidth: '700px', + padding: '24px 16px' +} as any) + +const TabButton = styled(Button, { + borderRadius: '0', + minWidth: '140px', + borderBottom: '2px solid transparent' +} as any) + +const InfoCard = styled(Card, { + padding: '0px', + marginTop: '16px', + backgroundColor: 'rgba(99, 102, 241, 0.03)', + border: '1px solid rgba(99, 102, 241, 0.1)' +} as any) + +export default function EarnPage() { + const earnState = useSnapshot(EarnStore.state) + const { eip155Address } = useSnapshot(SettingsStore.state) + const { refreshBalance, refreshPositions } = useEarnData() + + // Mock balance for demo - will be replaced with real balance + const [mockBalance] = useState('5000') + const [realBalance, setRealBalance] = useState('0') + const [isLoadingBalance, setIsLoadingBalance] = useState(false) + + // Fetch real balance when protocol changes + useEffect(() => { + if (earnState.selectedProtocol && eip155Address) { + setIsLoadingBalance(true) + refreshBalance().then(balance => { + setRealBalance(balance) + setIsLoadingBalance(false) + }) + } + }, [earnState.selectedProtocol, eip155Address, refreshBalance]) + + // Get available protocols for Base chain (hardcoded for POC) + const availableProtocols = useMemo(() => { + return getProtocolsByChain(earnState.selectedChainId) + }, [earnState.selectedChainId]) + + const handleProtocolSelect = (config: ProtocolConfig) => { + EarnStore.setSelectedProtocol(config) + EarnStore.setDepositAmount('') + } + + const handleAmountChange = (amount: string) => { + EarnStore.setDepositAmount(amount) + } + + const handleDeposit = async () => { + if (!earnState.selectedProtocol) { + styledToast('Please select a protocol', 'error') + return + } + + if (!earnState.depositAmount || parseFloat(earnState.depositAmount) <= 0) { + styledToast('Please enter a valid amount', 'error') + return + } + + if (parseFloat(earnState.depositAmount) > parseFloat(mockBalance)) { + styledToast('Insufficient balance', 'error') + return + } + + // Phase 3 will implement actual deposit logic + styledToast( + `Deposit functionality coming in Phase 3! Would deposit ${earnState.depositAmount} USDC to ${earnState.selectedProtocol.protocol.displayName}`, + 'success' + ) + } + + const handleWithdraw = async (position: UserPosition) => { + // Phase 4 will implement actual withdrawal logic + styledToast( + `Withdrawal functionality coming in Phase 4! Would withdraw from ${position.protocol}`, + 'success' + ) + } + + const calculateEstimatedRewards = useMemo(() => { + if ( + !earnState.selectedProtocol || + !earnState.depositAmount || + parseFloat(earnState.depositAmount) <= 0 + ) { + return null + } + + const amount = parseFloat(earnState.depositAmount) + const apy = earnState.selectedProtocol.apy / 100 + + return { + yearly: (amount * apy).toFixed(2), + monthly: ((amount * apy) / 12).toFixed(2), + daily: ((amount * apy) / 365).toFixed(2) + } + }, [earnState.selectedProtocol, earnState.depositAmount]) + + return ( + + + + {/* Tab Navigation - Minimal Style */} +
+ + EarnStore.setActiveTab('earn')} + css={{ + borderBottomColor: + earnState.activeTab === 'earn' ? 'rgb(99, 102, 241)' : 'transparent', + color: earnState.activeTab === 'earn' ? 'white' : '$gray600', + fontSize: '14px' + }} + > + Earn + + EarnStore.setActiveTab('positions')} + css={{ + borderBottomColor: + earnState.activeTab === 'positions' ? 'rgb(99, 102, 241)' : 'transparent', + color: earnState.activeTab === 'positions' ? 'white' : '$gray600', + fontSize: '14px' + }} + > + My Positions + + +
+ + {/* Earn Tab Content */} + {earnState.activeTab === 'earn' && ( + + + Select Protocol + + + {/* Protocol Cards */} + {availableProtocols.length > 0 ? ( + availableProtocols.map((config, index) => ( + + )) + ) : ( + + No protocols available for the selected chain + + )} + + {/* Deposit Section */} + {earnState.selectedProtocol && ( + + + + 0 ? realBalance : mockBalance} + tokenSymbol={earnState.selectedProtocol.token.symbol} + label="Amount to Deposit" + placeholder="0.00" + disabled={isLoadingBalance} + /> + + {/* Estimated Rewards */} + {calculateEstimatedRewards && ( + + + + Estimated Rewards + + + + + Daily + + +{calculateEstimatedRewards.daily}{' '} + {earnState.selectedProtocol.token.symbol} + + + + Monthly + + +{calculateEstimatedRewards.monthly}{' '} + {earnState.selectedProtocol.token.symbol} + + + + Yearly + + +{calculateEstimatedRewards.yearly}{' '} + {earnState.selectedProtocol.token.symbol} + + + + + )} + + {/* Deposit Button */} + + + + + {/* Info Messages */} + + + • First-time staking requires approval transaction + + + • Rewards are automatically compounded + + + • You can withdraw anytime without lock-up period + + + + )} + + )} + + {/* My Positions Tab Content */} + {earnState.activeTab === 'positions' && ( + + + Your Active Positions + + + {earnState.positionsLoading ? ( + + + Loading positions... + + ) : earnState.positions.length > 0 ? ( + earnState.positions.map((position, index) => ( + + )) + ) : ( + + No active positions + + Start earning by depositing your assets in the Earn tab + + + + )} + + )} + + {/* Footer */} + + + Powered by Aave V3 and Spark Protocol + + +
+
+ ) +} diff --git a/advanced/wallets/react-wallet-v2/src/store/EarnStore.ts b/advanced/wallets/react-wallet-v2/src/store/EarnStore.ts new file mode 100644 index 000000000..fa43d1bc6 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/store/EarnStore.ts @@ -0,0 +1,157 @@ +import { proxy } from 'valtio' +import { UserPosition, TransactionState, ProtocolConfig } from '@/types/earn' + +/** + * Types + */ +interface State { + // Selected protocol and chain + selectedProtocol: ProtocolConfig | null + selectedChainId: number + + // User positions + positions: UserPosition[] + positionsLoading: boolean + + // Transaction states + approvalState: TransactionState + depositState: TransactionState + withdrawState: TransactionState + + // Input amounts + depositAmount: string + withdrawAmount: string + + // Active tab + activeTab: 'earn' | 'positions' +} + +/** + * State + */ +const state = proxy({ + selectedProtocol: null, + selectedChainId: 8453, // Base by default + + positions: [], + positionsLoading: false, + + approvalState: { + status: 'idle' + }, + depositState: { + status: 'idle' + }, + withdrawState: { + status: 'idle' + }, + + depositAmount: '', + withdrawAmount: '', + + activeTab: 'earn' +}) + +/** + * Store / Actions + */ +const EarnStore = { + state, + + // Protocol selection + setSelectedProtocol(protocol: ProtocolConfig | null) { + state.selectedProtocol = protocol + }, + + setSelectedChainId(chainId: number) { + state.selectedChainId = chainId + // Reset selected protocol when chain changes + state.selectedProtocol = null + }, + + // Tab management + setActiveTab(tab: 'earn' | 'positions') { + state.activeTab = tab + }, + + // Amount management + setDepositAmount(amount: string) { + state.depositAmount = amount + }, + + setWithdrawAmount(amount: string) { + state.withdrawAmount = amount + }, + + // Position management + setPositions(positions: UserPosition[]) { + state.positions = positions + }, + + setPositionsLoading(loading: boolean) { + state.positionsLoading = loading + }, + + addPosition(position: UserPosition) { + state.positions.push(position) + }, + + removePosition(protocol: string, chainId: number) { + state.positions = state.positions.filter( + p => !(p.protocol === protocol && p.chainId === chainId) + ) + }, + + updatePosition(protocol: string, chainId: number, updates: Partial) { + const index = state.positions.findIndex(p => p.protocol === protocol && p.chainId === chainId) + if (index !== -1) { + state.positions[index] = { ...state.positions[index], ...updates } + } + }, + + // Transaction state management + setApprovalState(txState: Partial) { + state.approvalState = { ...state.approvalState, ...txState } + }, + + setDepositState(txState: Partial) { + state.depositState = { ...state.depositState, ...txState } + }, + + setWithdrawState(txState: Partial) { + state.withdrawState = { ...state.withdrawState, ...txState } + }, + + resetApprovalState() { + state.approvalState = { status: 'idle' } + }, + + resetDepositState() { + state.depositState = { status: 'idle' } + }, + + resetWithdrawState() { + state.withdrawState = { status: 'idle' } + }, + + // Reset all transaction states + resetAllTransactionStates() { + this.resetApprovalState() + this.resetDepositState() + this.resetWithdrawState() + }, + + // Reset entire store + reset() { + state.selectedProtocol = null + state.selectedChainId = 8453 + state.positions = [] + state.positionsLoading = false + state.depositAmount = '' + state.withdrawAmount = '' + state.activeTab = 'earn' + this.resetAllTransactionStates() + } +} + +export default EarnStore diff --git a/advanced/wallets/react-wallet-v2/src/types/earn.ts b/advanced/wallets/react-wallet-v2/src/types/earn.ts new file mode 100644 index 000000000..030f51738 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/types/earn.ts @@ -0,0 +1,100 @@ +/** + * Types and interfaces for the Earn feature + */ + +export type ProtocolType = 'aave' | 'spark' + +export type RiskLevel = 'Low' | 'Medium' | 'High' + +export type TransactionStatus = + | 'idle' + | 'approving' + | 'depositing' + | 'withdrawing' + | 'success' + | 'error' + +export interface Protocol { + id: ProtocolType + name: string + displayName: string + logo: string + description?: string +} + +export interface ProtocolConfig { + protocol: Protocol + chainId: number + chainName: string + token: { + symbol: string + address: string + decimals: number + logo: string + } + contracts: { + pool: string + poolDataProvider?: string + aToken?: string + variableDebtToken?: string + } + apy: number // Annual Percentage Yield + tvl: string // Total Value Locked (formatted string like "$125.4M") + riskLevel: RiskLevel + features?: { + autoCompound: boolean + lockupPeriod: boolean + instantWithdraw: boolean + } +} + +export interface UserPosition { + protocol: ProtocolType + chainId: number + token: string + principal: string // Amount deposited (in token units) + principalUSD: string // USD value of principal + rewards: string // Accumulated rewards (in token units) + rewardsUSD: string // USD value of rewards + total: string // principal + rewards + totalUSD: string // USD value of total + apy: number // Current APY + depositedAt: number // Timestamp + lastUpdateAt: number // Timestamp +} + +export interface DepositQuote { + protocol: ProtocolType + chainId: number + amount: string + estimatedAPY: number + estimatedYearlyRewards: string + estimatedMonthlyRewards: string + estimatedDailyRewards: string + gasEstimate?: string + requiresApproval: boolean +} + +export interface WithdrawQuote { + protocol: ProtocolType + chainId: number + amount: string + principal: string + rewards: string + total: string + gasEstimate?: string +} + +export interface TransactionState { + status: TransactionStatus + txHash?: string + error?: string + message?: string +} + +export interface EarnFilters { + minAPY?: number + maxRisk?: RiskLevel + chains?: number[] + protocols?: ProtocolType[] +} diff --git a/advanced/wallets/react-wallet-v2/src/utils/EarnService.ts b/advanced/wallets/react-wallet-v2/src/utils/EarnService.ts new file mode 100644 index 000000000..ff688ed69 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/utils/EarnService.ts @@ -0,0 +1,414 @@ +/** + * Earn Service - Orchestrates interactions with lending protocols + * Provides a unified interface for Aave and Spark protocols + */ + +import AaveLib from '@/lib/AaveLib' +import SparkLib from '@/lib/SparkLib' +import { ProtocolConfig, UserPosition, DepositQuote, WithdrawQuote } from '@/types/earn' +import { PROTOCOL_CONFIGS, EARN_CHAINS } from '@/data/EarnProtocolsData' + +// Cache for protocol instances +const protocolInstances: { + aave: Map + spark: Map +} = { + aave: new Map(), + spark: new Map() +} + +/** + * Get Aave instance for a specific chain + */ +function getAaveInstance(chainId: number): AaveLib | null { + if (protocolInstances.aave.has(chainId)) { + return protocolInstances.aave.get(chainId)! + } + + const config = PROTOCOL_CONFIGS.find(c => c.protocol.id === 'aave' && c.chainId === chainId) + if (!config) return null + + const rpcUrl = Object.values(EARN_CHAINS).find(c => c.id === chainId)?.rpc + if (!rpcUrl) return null + + const instance = new AaveLib(rpcUrl, config.contracts.pool, chainId) + protocolInstances.aave.set(chainId, instance) + return instance +} + +/** + * Get Spark instance for a specific chain + */ +function getSparkInstance(chainId: number): SparkLib | null { + if (protocolInstances.spark.has(chainId)) { + return protocolInstances.spark.get(chainId)! + } + + const config = PROTOCOL_CONFIGS.find(c => c.protocol.id === 'spark' && c.chainId === chainId) + if (!config) return null + + const rpcUrl = Object.values(EARN_CHAINS).find(c => c.id === chainId)?.rpc + if (!rpcUrl) return null + + const instance = new SparkLib(rpcUrl, config.contracts.pool, chainId) + protocolInstances.spark.set(chainId, instance) + return instance +} + +/** + * Fetch live APY for a protocol + */ +export async function fetchProtocolAPY(config: ProtocolConfig): Promise { + try { + // For now, use the configured APY value + // In production, we would fetch real-time data from the blockchain + // The rayToAPY method is private and requires BigNumber conversion + + // TODO: Implement real-time APY fetching once contract integration is tested + return config.apy + } catch (error) { + console.error(`Error fetching APY for ${config.protocol.name}:`, error) + return config.apy // Fallback to config value + } +} + +/** + * Fetch all protocol APYs + */ +export async function fetchAllProtocolAPYs(chainId: number): Promise> { + const configs = PROTOCOL_CONFIGS.filter(c => c.chainId === chainId) + const apyMap = new Map() + + const promises = configs.map(async config => { + const apy = await fetchProtocolAPY(config) + apyMap.set(`${config.protocol.id}-${config.chainId}`, apy) + }) + + await Promise.allSettled(promises) + return apyMap +} + +/** + * Get user's token balance + */ +export async function getUserTokenBalance( + config: ProtocolConfig, + userAddress: string +): Promise { + try { + // TODO: Re-enable once correct contract addresses are verified + // For now, return mock balance to avoid contract errors + console.log(`Using mock balance for ${config.protocol.name} - contract integration pending`) + return '5000' // Mock balance + + /* Disabled until contract addresses are verified + if (config.protocol.id === 'aave') { + const aave = getAaveInstance(config.chainId) + if (!aave) return '0' + return await aave.getTokenBalance(config.token.address, userAddress) + } else if (config.protocol.id === 'spark') { + const spark = getSparkInstance(config.chainId) + if (!spark) return '0' + return await spark.getTokenBalance(config.token.address, userAddress) + } + return '0' + */ + } catch (error) { + console.error('Error fetching token balance:', error) + return '0' + } +} + +/** + * Get user's position in a protocol + */ +export async function getUserProtocolPosition( + config: ProtocolConfig, + userAddress: string +): Promise { + try { + // TODO: Re-enable once correct contract addresses are verified + // For now, return null to avoid contract errors + // The contract addresses need to be verified on Base chain + + console.log( + `Skipping position fetch for ${config.protocol.name} on chain ${config.chainId} - contract integration pending` + ) + return null + + /* Disabled until contract addresses are verified + if (config.protocol.id === 'aave') { + const aave = getAaveInstance(config.chainId) + if (!aave) return null + + const position = await aave.getUserPosition( + userAddress, + config.token.address, + config.token.decimals + ) + + // Only return if user has a balance + if (parseFloat(position.supplied) === 0) return null + + // Calculate rewards (mock for now - real rewards need more complex calculation) + const daysDeposited = 30 // Mock + const dailyRate = position.apy / 365 / 100 + const rewards = (parseFloat(position.supplied) * dailyRate * daysDeposited).toFixed(6) + const total = (parseFloat(position.supplied) + parseFloat(rewards)).toFixed(6) + + return { + protocol: config.protocol.id, + chainId: config.chainId, + token: config.token.symbol, + principal: position.supplied, + principalUSD: position.suppliedUSD, + rewards, + rewardsUSD: rewards, // 1:1 for stablecoins + total, + totalUSD: total, + apy: position.apy, + depositedAt: Date.now() - daysDeposited * 24 * 60 * 60 * 1000, // Mock + lastUpdateAt: Date.now() + } + } else if (config.protocol.id === 'spark') { + const spark = getSparkInstance(config.chainId) + if (!spark) return null + + const position = await spark.getUserPosition( + userAddress, + config.token.address, + config.token.decimals + ) + + if (parseFloat(position.supplied) === 0) return null + + const daysDeposited = 30 + const dailyRate = position.apy / 365 / 100 + const rewards = (parseFloat(position.supplied) * dailyRate * daysDeposited).toFixed(6) + const total = (parseFloat(position.supplied) + parseFloat(rewards)).toFixed(6) + + return { + protocol: config.protocol.id, + chainId: config.chainId, + token: config.token.symbol, + principal: position.supplied, + principalUSD: position.suppliedUSD, + rewards, + rewardsUSD: rewards, + total, + totalUSD: total, + apy: position.apy, + depositedAt: Date.now() - daysDeposited * 24 * 60 * 60 * 1000, + lastUpdateAt: Date.now() + } + } + + return null + */ + } catch (error) { + console.error('Error fetching user position:', error) + return null + } +} + +/** + * Get all user positions across protocols + */ +export async function getAllUserPositions( + userAddress: string, + chainId?: number +): Promise { + const configs = chainId ? PROTOCOL_CONFIGS.filter(c => c.chainId === chainId) : PROTOCOL_CONFIGS + + const positions: UserPosition[] = [] + + const promises = configs.map(async config => { + const position = await getUserProtocolPosition(config, userAddress) + if (position) { + positions.push(position) + } + }) + + await Promise.allSettled(promises) + return positions +} + +/** + * Check if approval is needed + */ +export async function checkApprovalNeeded( + config: ProtocolConfig, + userAddress: string, + amount: string +): Promise { + try { + if (config.protocol.id === 'aave') { + const aave = getAaveInstance(config.chainId) + if (!aave) return true + + const allowance = await aave.checkAllowance( + config.token.address, + userAddress, + config.contracts.pool + ) + return parseFloat(allowance) < parseFloat(amount) + } else if (config.protocol.id === 'spark') { + const spark = getSparkInstance(config.chainId) + if (!spark) return true + + const allowance = await spark.checkAllowance( + config.token.address, + userAddress, + config.contracts.pool + ) + return parseFloat(allowance) < parseFloat(amount) + } + return true + } catch (error) { + console.error('Error checking approval:', error) + return true + } +} + +/** + * Get deposit quote with estimated rewards + */ +export async function getDepositQuote( + config: ProtocolConfig, + userAddress: string, + amount: string +): Promise { + const apy = await fetchProtocolAPY(config) + const requiresApproval = await checkApprovalNeeded(config, userAddress, amount) + + const amountNum = parseFloat(amount) + const apyDecimal = apy / 100 + + return { + protocol: config.protocol.id, + chainId: config.chainId, + amount, + estimatedAPY: apy, + estimatedYearlyRewards: (amountNum * apyDecimal).toFixed(6), + estimatedMonthlyRewards: ((amountNum * apyDecimal) / 12).toFixed(6), + estimatedDailyRewards: ((amountNum * apyDecimal) / 365).toFixed(6), + requiresApproval + } +} + +/** + * Get withdrawal quote + */ +export async function getWithdrawQuote( + config: ProtocolConfig, + position: UserPosition +): Promise { + return { + protocol: config.protocol.id, + chainId: config.chainId, + amount: position.total, + principal: position.principal, + rewards: position.rewards, + total: position.total + } +} + +/** + * Build approval transaction + */ +export function buildApprovalTransaction(config: ProtocolConfig, amount: string) { + if (config.protocol.id === 'aave') { + const aave = getAaveInstance(config.chainId) + if (!aave) throw new Error('Aave instance not available') + return aave.buildApproveTransaction( + config.token.address, + config.contracts.pool, + amount, + config.token.decimals + ) + } else if (config.protocol.id === 'spark') { + const spark = getSparkInstance(config.chainId) + if (!spark) throw new Error('Spark instance not available') + return spark.buildApproveTransaction( + config.token.address, + config.contracts.pool, + amount, + config.token.decimals + ) + } + throw new Error('Unknown protocol') +} + +/** + * Build deposit transaction + */ +export function buildDepositTransaction( + config: ProtocolConfig, + amount: string, + userAddress: string +) { + if (config.protocol.id === 'aave') { + const aave = getAaveInstance(config.chainId) + if (!aave) throw new Error('Aave instance not available') + return aave.buildSupplyTransaction( + config.token.address, + amount, + userAddress, + config.token.decimals + ) + } else if (config.protocol.id === 'spark') { + const spark = getSparkInstance(config.chainId) + if (!spark) throw new Error('Spark instance not available') + return spark.buildSupplyTransaction( + config.token.address, + amount, + userAddress, + config.token.decimals + ) + } + throw new Error('Unknown protocol') +} + +/** + * Build withdrawal transaction + */ +export function buildWithdrawTransaction( + config: ProtocolConfig, + amount: string, + userAddress: string +) { + if (config.protocol.id === 'aave') { + const aave = getAaveInstance(config.chainId) + if (!aave) throw new Error('Aave instance not available') + return aave.buildWithdrawTransaction( + config.token.address, + amount === 'all' ? 'max' : amount, + userAddress, + config.token.decimals + ) + } else if (config.protocol.id === 'spark') { + const spark = getSparkInstance(config.chainId) + if (!spark) throw new Error('Spark instance not available') + return spark.buildWithdrawTransaction( + config.token.address, + amount === 'all' ? 'max' : amount, + userAddress, + config.token.decimals + ) + } + throw new Error('Unknown protocol') +} + +export default { + fetchProtocolAPY, + fetchAllProtocolAPYs, + getUserTokenBalance, + getUserProtocolPosition, + getAllUserPositions, + checkApprovalNeeded, + getDepositQuote, + getWithdrawQuote, + buildApprovalTransaction, + buildDepositTransaction, + buildWithdrawTransaction +} From 4b19e436df8efd32c70859c8bfac1d45df6a7675 Mon Sep 17 00:00:00 2001 From: Gancho Radkov Date: Wed, 5 Nov 2025 10:37:31 +0200 Subject: [PATCH 2/7] chore: mock positions --- .../src/components/Earn/PositionCard.tsx | 218 ++++++++++++------ .../react-wallet-v2/src/pages/earn.tsx | 38 ++- 2 files changed, 181 insertions(+), 75 deletions(-) diff --git a/advanced/wallets/react-wallet-v2/src/components/Earn/PositionCard.tsx b/advanced/wallets/react-wallet-v2/src/components/Earn/PositionCard.tsx index d5ef7e99e..f6560e06e 100644 --- a/advanced/wallets/react-wallet-v2/src/components/Earn/PositionCard.tsx +++ b/advanced/wallets/react-wallet-v2/src/components/Earn/PositionCard.tsx @@ -1,23 +1,23 @@ -import { Card, Row, Col, Text, Button, styled } from '@nextui-org/react' +import { Card, Row, Col, Text, Button, styled, Input, Divider } from '@nextui-org/react' import { UserPosition } from '@/types/earn' -import { PROTOCOLS } from '@/data/EarnProtocolsData' -import Image from 'next/image' -import { useMemo } from 'react' +import { PROTOCOL_CONFIGS } from '@/data/EarnProtocolsData' +import { useMemo, useState, ChangeEvent } from 'react' // Simple Badge component since NextUI v1 doesn't have Badge const Badge = styled('span', { display: 'inline-block', - padding: '4px 12px', - borderRadius: '12px', - fontSize: '12px', - fontWeight: '600', - backgroundColor: 'rgba(23, 201, 100, 0.15)', - color: '#17c964' + padding: '2px 8px', + borderRadius: '4px', + fontSize: '20px', + fontWeight: '500', + backgroundColor: 'rgba(34, 197, 94, 0.1)', + color: 'rgb(34, 197, 94)' } as any) const StyledCard = styled(Card, { - padding: '$6', - marginBottom: '$6' + padding: '0px', + marginBottom: '12px', + border: '1px solid rgba(255, 255, 255, 0.1)' } as any) const StyledText = styled(Text, { @@ -30,7 +30,17 @@ interface PositionCardProps { } export default function PositionCard({ position, onWithdraw }: PositionCardProps) { - const protocol = PROTOCOLS[position.protocol.toUpperCase()] + const [withdrawAmount, setWithdrawAmount] = useState('') + + const protocolConfig = useMemo( + () => + PROTOCOL_CONFIGS.find( + p => p.protocol.id === position.protocol && p.chainId === position.chainId + ), + [position.protocol, position.chainId] + ) + + const protocol = protocolConfig?.protocol const formattedDepositDate = useMemo(() => { const date = new Date(position.depositedAt) @@ -47,56 +57,59 @@ export default function PositionCard({ position, onWithdraw }: PositionCardProps return Math.floor(diff / (1000 * 60 * 60 * 24)) }, [position.depositedAt]) + const handleWithdrawAmountChange = (e: ChangeEvent) => { + const inputValue = e.target.value + // Allow only numbers and decimal point + if (inputValue === '' || /^\d*\.?\d*$/.test(inputValue)) { + setWithdrawAmount(inputValue) + } + } + + const handleMaxClick = () => { + setWithdrawAmount(position.total) + } + + const handleWithdrawClick = () => { + onWithdraw(position) + } + return ( - - - {protocol?.logo && ( - {protocol.name} - )} -
- - {protocol?.displayName || position.protocol} - - - {position.token} • Deposited {formattedDepositDate} ({durationDays} days) - -
- + {/* Header Row: Protocol Name and APY */} + + + {protocol?.displayName || position.protocol} + + {position.apy.toFixed(2)}% APY + - - {position.apy.toFixed(2)}% APY - + {/* Second Row: Token and Chain */} + + + {position.token} • Base + - - - - Principal + {/* Third Row: Deposit Amount (left) and Rewards (right) */} + + + + Deposit Amount - + {parseFloat(position.principal).toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 6 + minimumFractionDigits: 0, + maximumFractionDigits: 2 })}{' '} {position.token} - - ${position.principalUSD} - - - + + Rewards Earned - + + {parseFloat(position.rewards).toLocaleString('en-US', { minimumFractionDigits: 2, @@ -104,32 +117,93 @@ export default function PositionCard({ position, onWithdraw }: PositionCardProps })}{' '} {position.token} - - ${position.rewardsUSD} - - - - - - Total Value - - - {parseFloat(position.total).toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 6 - })}{' '} - {position.token} - - - ${position.totalUSD} - + + {/* Withdrawal Input */} + +
+ + Amount to Withdraw + + Available:{' '} + {parseFloat(position.total).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 6 + })}{' '} + {position.token} + + - - +
+ +
+ {position.token} + +
+
+
+
+ + {/* Withdraw Button */} +
+ +
) diff --git a/advanced/wallets/react-wallet-v2/src/pages/earn.tsx b/advanced/wallets/react-wallet-v2/src/pages/earn.tsx index a0c3d7fce..1a7150554 100644 --- a/advanced/wallets/react-wallet-v2/src/pages/earn.tsx +++ b/advanced/wallets/react-wallet-v2/src/pages/earn.tsx @@ -54,6 +54,38 @@ export default function EarnPage() { const [realBalance, setRealBalance] = useState('0') const [isLoadingBalance, setIsLoadingBalance] = useState(false) + // Mock positions for UI testing + const [mockPositions] = useState([ + { + protocol: 'aave', + chainId: 8453, + token: 'USDC', + principal: '1000.00', + principalUSD: '1,000.00', + rewards: '12.50', + rewardsUSD: '12.50', + total: '1012.50', + totalUSD: '1,012.50', + apy: 4.35, + depositedAt: Date.now() - 30 * 24 * 60 * 60 * 1000, // 30 days ago + lastUpdateAt: Date.now() + }, + { + protocol: 'spark', + chainId: 8453, + token: 'USDC', + principal: '500.00', + principalUSD: '500.00', + rewards: '8.20', + rewardsUSD: '8.20', + total: '508.20', + totalUSD: '508.20', + apy: 4.82, + depositedAt: Date.now() - 15 * 24 * 60 * 60 * 1000, // 15 days ago + lastUpdateAt: Date.now() + } + ]) + // Fetch real balance when protocol changes useEffect(() => { if (earnState.selectedProtocol && eip155Address) { @@ -341,8 +373,8 @@ export default function EarnPage() { Loading positions... - ) : earnState.positions.length > 0 ? ( - earnState.positions.map((position, index) => ( + ) : mockPositions.length > 0 ? ( + mockPositions.map((position, index) => ( )) ) : ( - + No active positions Start earning by depositing your assets in the Earn tab From 5901aa8c69d1a265576f2f14e77ff80e6df3bdd1 Mon Sep 17 00:00:00 2001 From: Gancho Radkov Date: Wed, 5 Nov 2025 10:48:12 +0200 Subject: [PATCH 3/7] chore: styles --- .../src/components/Earn/PositionCard.tsx | 2 +- .../src/components/Earn/ProtocolCard.tsx | 45 ++++++++++--------- .../react-wallet-v2/src/pages/earn.tsx | 9 +++- 3 files changed, 33 insertions(+), 23 deletions(-) diff --git a/advanced/wallets/react-wallet-v2/src/components/Earn/PositionCard.tsx b/advanced/wallets/react-wallet-v2/src/components/Earn/PositionCard.tsx index f6560e06e..7b6e6dd61 100644 --- a/advanced/wallets/react-wallet-v2/src/components/Earn/PositionCard.tsx +++ b/advanced/wallets/react-wallet-v2/src/components/Earn/PositionCard.tsx @@ -8,7 +8,7 @@ const Badge = styled('span', { display: 'inline-block', padding: '2px 8px', borderRadius: '4px', - fontSize: '20px', + fontSize: '16px', fontWeight: '500', backgroundColor: 'rgba(34, 197, 94, 0.1)', color: 'rgb(34, 197, 94)' diff --git a/advanced/wallets/react-wallet-v2/src/components/Earn/ProtocolCard.tsx b/advanced/wallets/react-wallet-v2/src/components/Earn/ProtocolCard.tsx index 1caf40c1e..7373bd59c 100644 --- a/advanced/wallets/react-wallet-v2/src/components/Earn/ProtocolCard.tsx +++ b/advanced/wallets/react-wallet-v2/src/components/Earn/ProtocolCard.tsx @@ -2,7 +2,18 @@ import { Card, Row, Col, Text, styled } from '@nextui-org/react' import { ProtocolConfig } from '@/types/earn' import Image from 'next/image' -// Minimal Badge component +// APY Badge component - matching PositionCard +const APYBadge = styled('span', { + display: 'inline-block', + padding: '2px 8px', + borderRadius: '4px', + fontSize: '16px', + fontWeight: '500', + backgroundColor: 'rgba(34, 197, 94, 0.1)', + color: 'rgb(34, 197, 94)' +} as any) + +// Minimal Badge component for Risk const Badge = styled('span', { display: 'inline-flex', padding: '2px 8px', @@ -12,27 +23,21 @@ const Badge = styled('span', { variants: { color: { success: { - backgroundColor: 'rgba(34, 197, 94, 0.1)', color: 'rgb(34, 197, 94)' }, warning: { - backgroundColor: 'rgba(251, 191, 36, 0.1)', color: 'rgb(251, 191, 36)' }, error: { - backgroundColor: 'rgba(239, 68, 68, 0.1)', color: 'rgb(239, 68, 68)' }, primary: { - backgroundColor: 'rgba(99, 102, 241, 0.1)', color: 'rgb(99, 102, 241)' }, secondary: { - backgroundColor: 'rgba(168, 85, 247, 0.1)', color: 'rgb(168, 85, 247)' }, default: { - backgroundColor: 'rgba(156, 163, 175, 0.1)', color: 'rgb(156, 163, 175)' } } @@ -86,22 +91,20 @@ export default function ProtocolCard({ config, selected, onSelect }: ProtocolCar display: 'flex', justifyContent: 'space-between', alignItems: 'center', - marginBottom: '10px' + marginBottom: '2px' }} > -
- - {config.protocol.displayName} - - - {config.token.symbol} • {config.chainName} - -
-
- - {config.apy.toFixed(2)}% APY - -
+ + {config.protocol.displayName} + + {config.apy.toFixed(2)}% APY + + + {/* Second Row: Token and Chain */} +
+ + {config.token.symbol} • {config.chainName} +
{/* Details */} diff --git a/advanced/wallets/react-wallet-v2/src/pages/earn.tsx b/advanced/wallets/react-wallet-v2/src/pages/earn.tsx index 1a7150554..f6a982f17 100644 --- a/advanced/wallets/react-wallet-v2/src/pages/earn.tsx +++ b/advanced/wallets/react-wallet-v2/src/pages/earn.tsx @@ -102,6 +102,13 @@ export default function EarnPage() { return getProtocolsByChain(earnState.selectedChainId) }, [earnState.selectedChainId]) + // Auto-select first protocol on mount if none selected + useEffect(() => { + if (!earnState.selectedProtocol && availableProtocols.length > 0) { + EarnStore.setSelectedProtocol(availableProtocols[0]) + } + }, [availableProtocols, earnState.selectedProtocol]) + const handleProtocolSelect = (config: ProtocolConfig) => { EarnStore.setSelectedProtocol(config) EarnStore.setDepositAmount('') @@ -164,7 +171,7 @@ export default function EarnPage() { return ( - + {/* Tab Navigation - Minimal Style */}
From 73930b74fc56a3b8a6c1f68fb7fe768cf16feead Mon Sep 17 00:00:00 2001 From: Gancho Radkov Date: Wed, 5 Nov 2025 11:17:33 +0200 Subject: [PATCH 4/7] feat: apy --- .../src/components/Earn/ProtocolCard.tsx | 9 +- .../src/data/EarnProtocolsData.ts | 8 +- .../react-wallet-v2/src/hooks/useEarnData.ts | 25 +- .../react-wallet-v2/src/pages/earn.tsx | 203 +++++++++++++--- .../react-wallet-v2/src/store/EarnStore.ts | 49 +++- .../react-wallet-v2/src/utils/EarnService.ts | 221 ++++++++++-------- .../src/utils/EarnTransactionService.ts | 171 ++++++++++++++ 7 files changed, 549 insertions(+), 137 deletions(-) create mode 100644 advanced/wallets/react-wallet-v2/src/utils/EarnTransactionService.ts diff --git a/advanced/wallets/react-wallet-v2/src/components/Earn/ProtocolCard.tsx b/advanced/wallets/react-wallet-v2/src/components/Earn/ProtocolCard.tsx index 7373bd59c..12370ade5 100644 --- a/advanced/wallets/react-wallet-v2/src/components/Earn/ProtocolCard.tsx +++ b/advanced/wallets/react-wallet-v2/src/components/Earn/ProtocolCard.tsx @@ -1,6 +1,8 @@ import { Card, Row, Col, Text, styled } from '@nextui-org/react' import { ProtocolConfig } from '@/types/earn' import Image from 'next/image' +import { useSnapshot } from 'valtio' +import EarnStore from '@/store/EarnStore' // APY Badge component - matching PositionCard const APYBadge = styled('span', { @@ -63,6 +65,11 @@ interface ProtocolCardProps { } export default function ProtocolCard({ config, selected, onSelect }: ProtocolCardProps) { + const { apyMap } = useSnapshot(EarnStore.state) + + // Get live APY from store, fallback to config APY + const displayAPY = apyMap.get(`${config.protocol.id}-${config.chainId}`) ?? config.apy + const getRiskColor = (risk: string) => { switch (risk) { case 'Low': @@ -97,7 +104,7 @@ export default function ProtocolCard({ config, selected, onSelect }: ProtocolCar {config.protocol.displayName} - {config.apy.toFixed(2)}% APY + {displayAPY.toFixed(2)}% APY
{/* Second Row: Token and Chain */} diff --git a/advanced/wallets/react-wallet-v2/src/data/EarnProtocolsData.ts b/advanced/wallets/react-wallet-v2/src/data/EarnProtocolsData.ts index 6f9301116..59c55c80d 100644 --- a/advanced/wallets/react-wallet-v2/src/data/EarnProtocolsData.ts +++ b/advanced/wallets/react-wallet-v2/src/data/EarnProtocolsData.ts @@ -90,15 +90,16 @@ export const PROTOCOL_CONFIGS: ProtocolConfig[] = [ instantWithdraw: true } }, - // Spark Protocol on Base + // NOTE: Spark Protocol does not exist on Base - commenting out to prevent errors + /* { protocol: PROTOCOLS.SPARK, chainId: EARN_CHAINS.BASE.id, chainName: EARN_CHAINS.BASE.name, token: USDC_TOKENS[EARN_CHAINS.BASE.id], contracts: { - pool: '0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB', // Placeholder - update with actual Spark address - poolDataProvider: '0x2d8A3C5677189723C4cB8873CfC9C8976FDF38Ac' // Placeholder + pool: '0x4e65fE4DbA92790696d040ac24Aa414708F5c0AB', + poolDataProvider: '0x2d8A3C5677189723C4cB8873CfC9C8976FDF38Ac' }, apy: 4.82, tvl: '$125.4M', @@ -109,6 +110,7 @@ export const PROTOCOL_CONFIGS: ProtocolConfig[] = [ instantWithdraw: true } }, + */ // Aave V3 on Ethereum { protocol: PROTOCOLS.AAVE, diff --git a/advanced/wallets/react-wallet-v2/src/hooks/useEarnData.ts b/advanced/wallets/react-wallet-v2/src/hooks/useEarnData.ts index 4ff144838..6ca04ec95 100644 --- a/advanced/wallets/react-wallet-v2/src/hooks/useEarnData.ts +++ b/advanced/wallets/react-wallet-v2/src/hooks/useEarnData.ts @@ -20,12 +20,16 @@ export function useEarnData() { if (!selectedChainId) return try { + console.log('Refreshing APYs for chain', selectedChainId) const apyMap = await fetchAllProtocolAPYs(selectedChainId) - // Update protocol configs with live APYs - // Note: This would need a more sophisticated state management approach - // For now, APYs are fetched but not persisted to the global state - console.log('Fetched APYs:', Array.from(apyMap.entries())) + // Store APYs in the store + apyMap.forEach((apy, key) => { + const [protocolId, chainIdStr] = key.split('-') + EarnStore.setAPY(protocolId, parseInt(chainIdStr), apy) + }) + + console.log('APY refresh complete:', Array.from(apyMap.entries())) } catch (error) { console.error('Error refreshing APYs:', error) } @@ -70,17 +74,10 @@ export function useEarnData() { await Promise.all([refreshAPYs(), refreshPositions()]) }, [refreshAPYs, refreshPositions]) - // Auto-refresh on mount and when dependencies change + // Auto-fetch APYs on mount (with 2-minute cache, safe from spam) useEffect(() => { - refreshAllData() - - // Set up periodic refresh (every 30 seconds) - const interval = setInterval(() => { - refreshAllData() - }, 30000) - - return () => clearInterval(interval) - }, [refreshAllData]) + refreshAPYs() + }, [refreshAPYs]) return { refreshAPYs, diff --git a/advanced/wallets/react-wallet-v2/src/pages/earn.tsx b/advanced/wallets/react-wallet-v2/src/pages/earn.tsx index f6a982f17..a84218374 100644 --- a/advanced/wallets/react-wallet-v2/src/pages/earn.tsx +++ b/advanced/wallets/react-wallet-v2/src/pages/earn.tsx @@ -21,6 +21,13 @@ import { getProtocolsByChain } from '@/data/EarnProtocolsData' import { ProtocolConfig, UserPosition } from '@/types/earn' import { styledToast } from '@/utils/HelperUtil' import useEarnData from '@/hooks/useEarnData' +import { checkApprovalNeeded } from '@/utils/EarnService' +import { + sendApprovalTransaction, + sendDepositTransaction, + sendWithdrawTransaction +} from '@/utils/EarnTransactionService' +import { eip155Addresses } from '@/utils/EIP155WalletUtil' const StyledText = styled(Text, { fontWeight: 400 @@ -46,9 +53,22 @@ const InfoCard = styled(Card, { export default function EarnPage() { const earnState = useSnapshot(EarnStore.state) - const { eip155Address } = useSnapshot(SettingsStore.state) + const { eip155Address, account } = useSnapshot(SettingsStore.state) const { refreshBalance, refreshPositions } = useEarnData() + // Handle address selection + const handleAddressChange = (accountIndex: number) => { + SettingsStore.setAccount(accountIndex) + SettingsStore.setEIP155Address(eip155Addresses[accountIndex]) + styledToast(`Switched to Account ${accountIndex + 1}`, 'success') + } + + // Format address for display + const formatAddress = (address: string) => { + if (!address) return '' + return `${address.slice(0, 6)}...${address.slice(-4)}` + } + // Mock balance for demo - will be replaced with real balance const [mockBalance] = useState('5000') const [realBalance, setRealBalance] = useState('0') @@ -87,15 +107,16 @@ export default function EarnPage() { ]) // Fetch real balance when protocol changes - useEffect(() => { - if (earnState.selectedProtocol && eip155Address) { - setIsLoadingBalance(true) - refreshBalance().then(balance => { - setRealBalance(balance) - setIsLoadingBalance(false) - }) - } - }, [earnState.selectedProtocol, eip155Address, refreshBalance]) + // Disabled to prevent RPC spam - using mock balance instead + // useEffect(() => { + // if (earnState.selectedProtocol && eip155Address) { + // setIsLoadingBalance(true) + // refreshBalance().then(balance => { + // setRealBalance(balance) + // setIsLoadingBalance(false) + // }) + // } + // }, [earnState.selectedProtocol, eip155Address]) // Get available protocols for Base chain (hardcoded for POC) const availableProtocols = useMemo(() => { @@ -119,8 +140,8 @@ export default function EarnPage() { } const handleDeposit = async () => { - if (!earnState.selectedProtocol) { - styledToast('Please select a protocol', 'error') + if (!earnState.selectedProtocol || !eip155Address) { + styledToast('Please select a protocol and ensure your wallet is connected', 'error') return } @@ -129,24 +150,117 @@ export default function EarnPage() { return } - if (parseFloat(earnState.depositAmount) > parseFloat(mockBalance)) { + const balance = parseFloat(realBalance) > 0 ? realBalance : mockBalance + if (parseFloat(earnState.depositAmount) > parseFloat(balance)) { styledToast('Insufficient balance', 'error') return } - // Phase 3 will implement actual deposit logic - styledToast( - `Deposit functionality coming in Phase 3! Would deposit ${earnState.depositAmount} USDC to ${earnState.selectedProtocol.protocol.displayName}`, - 'success' - ) + try { + EarnStore.setTransactionStatus('approving') + EarnStore.setLoading(true) + + // Check if approval is needed + const needsApproval = await checkApprovalNeeded( + earnState.selectedProtocol, + eip155Address, + earnState.depositAmount + ) + + if (needsApproval) { + styledToast('Approving USDC spending...', 'default') + + // Send approval transaction + const approvalResult = await sendApprovalTransaction( + earnState.selectedProtocol, + earnState.depositAmount, + eip155Address + ) + + if (!approvalResult.success) { + throw new Error(approvalResult.error || 'Approval failed') + } + + styledToast('Approval confirmed! Now depositing...', 'success') + } + + // Send deposit transaction + EarnStore.setTransactionStatus('depositing') + styledToast('Depositing USDC...', 'default') + + const depositResult = await sendDepositTransaction( + earnState.selectedProtocol, + earnState.depositAmount, + eip155Address + ) + + if (!depositResult.success) { + throw new Error(depositResult.error || 'Deposit failed') + } + + EarnStore.setTransactionStatus('success', depositResult.txHash) + styledToast( + `Successfully deposited ${earnState.depositAmount} ${earnState.selectedProtocol.token.symbol}!`, + 'success' + ) + + // Reset form and refresh data + EarnStore.resetDepositForm() + refreshBalance() + refreshPositions() + } catch (error: any) { + console.error('Deposit error:', error) + EarnStore.setTransactionStatus('error') + styledToast(error.message || 'Transaction failed', 'error') + } finally { + EarnStore.setLoading(false) + } } const handleWithdraw = async (position: UserPosition) => { - // Phase 4 will implement actual withdrawal logic - styledToast( - `Withdrawal functionality coming in Phase 4! Would withdraw from ${position.protocol}`, - 'success' + if (!eip155Address) { + styledToast('Please ensure your wallet is connected', 'error') + return + } + + const config = availableProtocols.find( + p => p.protocol.id === position.protocol && p.chainId === position.chainId ) + + if (!config) { + styledToast('Protocol configuration not found', 'error') + return + } + + try { + EarnStore.setTransactionStatus('withdrawing') + EarnStore.setLoading(true) + + styledToast('Withdrawing funds...', 'default') + + const withdrawResult = await sendWithdrawTransaction( + config, + position.total, // Withdraw all + eip155Address + ) + + if (!withdrawResult.success) { + throw new Error(withdrawResult.error || 'Withdrawal failed') + } + + EarnStore.setTransactionStatus('success', withdrawResult.txHash) + styledToast(`Successfully withdrew ${position.total} ${position.token}!`, 'success') + + // Refresh data + refreshBalance() + refreshPositions() + } catch (error: any) { + console.error('Withdrawal error:', error) + EarnStore.setTransactionStatus('error') + styledToast(error.message || 'Transaction failed', 'error') + } finally { + EarnStore.setLoading(false) + } } const calculateEstimatedRewards = useMemo(() => { @@ -159,18 +273,46 @@ export default function EarnPage() { } const amount = parseFloat(earnState.depositAmount) - const apy = earnState.selectedProtocol.apy / 100 + + // Use live APY from store if available, otherwise use config APY + const liveAPY = earnState.apyMap.get( + `${earnState.selectedProtocol.protocol.id}-${earnState.selectedProtocol.chainId}` + ) + const apy = (liveAPY ?? earnState.selectedProtocol.apy) / 100 return { yearly: (amount * apy).toFixed(2), monthly: ((amount * apy) / 12).toFixed(2), daily: ((amount * apy) / 365).toFixed(2) } - }, [earnState.selectedProtocol, earnState.depositAmount]) + }, [earnState.selectedProtocol, earnState.depositAmount, earnState.apyMap]) return ( - + + +
+ Account: + +
+
+
{/* Tab Navigation - Minimal Style */}
@@ -326,13 +468,22 @@ export default function EarnPage() { } }} disabled={ + earnState.loading || !earnState.depositAmount || parseFloat(earnState.depositAmount) <= 0 || parseFloat(earnState.depositAmount) > parseFloat(mockBalance) } onClick={handleDeposit} > - Stake {earnState.selectedProtocol.token.symbol} + {earnState.loading ? ( + <> + + {earnState.transactionStatus === 'approving' && 'Approving...'} + {earnState.transactionStatus === 'depositing' && 'Depositing...'} + + ) : ( + `Stake ${earnState.selectedProtocol.token.symbol}` + )} diff --git a/advanced/wallets/react-wallet-v2/src/store/EarnStore.ts b/advanced/wallets/react-wallet-v2/src/store/EarnStore.ts index fa43d1bc6..719db9bb4 100644 --- a/advanced/wallets/react-wallet-v2/src/store/EarnStore.ts +++ b/advanced/wallets/react-wallet-v2/src/store/EarnStore.ts @@ -24,6 +24,14 @@ interface State { // Active tab activeTab: 'earn' | 'positions' + + // Simple transaction status for UI + transactionStatus: 'idle' | 'approving' | 'depositing' | 'withdrawing' | 'success' | 'error' + transactionHash: string | null + loading: boolean + + // APY map for live values + apyMap: Map } /** @@ -49,7 +57,13 @@ const state = proxy({ depositAmount: '', withdrawAmount: '', - activeTab: 'earn' + activeTab: 'earn', + + transactionStatus: 'idle', + transactionHash: null, + loading: false, + + apyMap: new Map() }) /** @@ -141,6 +155,36 @@ const EarnStore = { this.resetWithdrawState() }, + // Simple transaction status management for UI + setTransactionStatus( + status: 'idle' | 'approving' | 'depositing' | 'withdrawing' | 'success' | 'error', + txHash: string | null = null + ) { + state.transactionStatus = status + state.transactionHash = txHash + }, + + setLoading(loading: boolean) { + state.loading = loading + }, + + resetDepositForm() { + state.depositAmount = '' + state.transactionStatus = 'idle' + state.transactionHash = null + }, + + // APY management + setAPY(protocolId: string, chainId: number, apy: number) { + const key = `${protocolId}-${chainId}` + state.apyMap.set(key, apy) + }, + + getAPY(protocolId: string, chainId: number): number | undefined { + const key = `${protocolId}-${chainId}` + return state.apyMap.get(key) + }, + // Reset entire store reset() { state.selectedProtocol = null @@ -150,6 +194,9 @@ const EarnStore = { state.depositAmount = '' state.withdrawAmount = '' state.activeTab = 'earn' + state.transactionStatus = 'idle' + state.transactionHash = null + state.loading = false this.resetAllTransactionStates() } } diff --git a/advanced/wallets/react-wallet-v2/src/utils/EarnService.ts b/advanced/wallets/react-wallet-v2/src/utils/EarnService.ts index ff688ed69..c52cc9104 100644 --- a/advanced/wallets/react-wallet-v2/src/utils/EarnService.ts +++ b/advanced/wallets/react-wallet-v2/src/utils/EarnService.ts @@ -7,6 +7,7 @@ import AaveLib from '@/lib/AaveLib' import SparkLib from '@/lib/SparkLib' import { ProtocolConfig, UserPosition, DepositQuote, WithdrawQuote } from '@/types/earn' import { PROTOCOL_CONFIGS, EARN_CHAINS } from '@/data/EarnProtocolsData' +import { BigNumber } from 'ethers' // Cache for protocol instances const protocolInstances: { @@ -17,6 +18,10 @@ const protocolInstances: { spark: new Map() } +// APY Cache with timestamps +const apyCache: Map = new Map() +const APY_CACHE_DURATION = 2 * 60 * 1000 // 2 minutes in milliseconds + /** * Get Aave instance for a specific chain */ @@ -59,15 +64,86 @@ function getSparkInstance(chainId: number): SparkLib | null { * Fetch live APY for a protocol */ export async function fetchProtocolAPY(config: ProtocolConfig): Promise { + const cacheKey = `${config.protocol.id}-${config.chainId}` + + // Check cache first + const cached = apyCache.get(cacheKey) + if (cached && Date.now() - cached.timestamp < APY_CACHE_DURATION) { + console.log( + `Using cached APY for ${config.protocol.name} (${Math.floor( + (Date.now() - cached.timestamp) / 1000 + )}s old)` + ) + return cached.apy + } + try { - // For now, use the configured APY value - // In production, we would fetch real-time data from the blockchain - // The rayToAPY method is private and requires BigNumber conversion + let protocolLib: AaveLib | SparkLib | null = null - // TODO: Implement real-time APY fetching once contract integration is tested - return config.apy - } catch (error) { - console.error(`Error fetching APY for ${config.protocol.name}:`, error) + if (config.protocol.id === 'aave') { + protocolLib = getAaveInstance(config.chainId) + } else if (config.protocol.id === 'spark') { + protocolLib = getSparkInstance(config.chainId) + } + + if (!protocolLib) { + console.warn(`No protocol instance for ${config.protocol.id} on chain ${config.chainId}`) + return config.apy // Fallback to configured value + } + + console.log(`Fetching live APY for ${config.protocol.name} on chain ${config.chainId}...`) + + // Fetch reserve data to get current APY + const reserveData = await protocolLib.getReserveData(config.token.address) + + let apy = config.apy // Default fallback + + if (reserveData.liquidityRate && reserveData.liquidityRate !== '0') { + try { + // liquidityRate from Aave is the APR in Ray units (1e27) + // Not a per-second rate - it's already annualized! + const liquidityRateBN = BigNumber.from(reserveData.liquidityRate) + + // Convert from Ray to decimal APR + // APR = liquidityRate / 1e27 + const { formatUnits } = require('ethers/lib/utils') + const depositApr = parseFloat(formatUnits(liquidityRateBN, 27)) + + // Convert APR to APY accounting for daily compounding + // APY = (1 + APR/365)^365 - 1 + const depositApy = Math.pow(1 + depositApr / 365, 365) - 1 + + // Convert to percentage + apy = depositApy * 100 + + console.log(`Raw liquidityRate: ${reserveData.liquidityRate}`) + console.log(`Deposit APR (decimal): ${depositApr}`) + console.log(`Deposit APR (%): ${(depositApr * 100).toFixed(4)}%`) + console.log(`Deposit APY (%): ${apy.toFixed(4)}%`) + } catch (conversionError) { + console.warn('Error converting liquidityRate to APY:', conversionError) + apy = config.apy + } + } + + // Cache the result + apyCache.set(cacheKey, { apy, timestamp: Date.now() }) + console.log(`✓ Fetched APY for ${config.protocol.name}: ${apy.toFixed(2)}%`) + + return apy + } catch (error: any) { + // Handle rate limiting and other errors gracefully + if (error.code === 'CALL_EXCEPTION') { + console.warn( + `Contract call failed for ${config.protocol.name} on chain ${config.chainId} - using fallback APY` + ) + } else if (error.message?.includes('429') || error.message?.includes('Too Many Requests')) { + console.warn( + `Rate limited when fetching APY for ${config.protocol.name} - using fallback APY` + ) + } else { + console.error(`Error fetching APY for ${config.protocol.name}:`, error) + } return config.apy // Fallback to config value } } @@ -96,23 +172,20 @@ export async function getUserTokenBalance( userAddress: string ): Promise { try { - // TODO: Re-enable once correct contract addresses are verified - // For now, return mock balance to avoid contract errors - console.log(`Using mock balance for ${config.protocol.name} - contract integration pending`) - return '5000' // Mock balance + let protocolLib: AaveLib | SparkLib | null = null - /* Disabled until contract addresses are verified if (config.protocol.id === 'aave') { - const aave = getAaveInstance(config.chainId) - if (!aave) return '0' - return await aave.getTokenBalance(config.token.address, userAddress) + protocolLib = getAaveInstance(config.chainId) } else if (config.protocol.id === 'spark') { - const spark = getSparkInstance(config.chainId) - if (!spark) return '0' - return await spark.getTokenBalance(config.token.address, userAddress) + protocolLib = getSparkInstance(config.chainId) } - return '0' - */ + + if (!protocolLib) { + console.warn(`No protocol instance for ${config.protocol.id}`) + return '0' + } + + return await protocolLib.getTokenBalance(config.token.address, userAddress) } catch (error) { console.error('Error fetching token balance:', error) return '0' @@ -127,84 +200,48 @@ export async function getUserProtocolPosition( userAddress: string ): Promise { try { - // TODO: Re-enable once correct contract addresses are verified - // For now, return null to avoid contract errors - // The contract addresses need to be verified on Base chain - - console.log( - `Skipping position fetch for ${config.protocol.name} on chain ${config.chainId} - contract integration pending` - ) - return null + let protocolLib: AaveLib | SparkLib | null = null - /* Disabled until contract addresses are verified if (config.protocol.id === 'aave') { - const aave = getAaveInstance(config.chainId) - if (!aave) return null - - const position = await aave.getUserPosition( - userAddress, - config.token.address, - config.token.decimals - ) - - // Only return if user has a balance - if (parseFloat(position.supplied) === 0) return null - - // Calculate rewards (mock for now - real rewards need more complex calculation) - const daysDeposited = 30 // Mock - const dailyRate = position.apy / 365 / 100 - const rewards = (parseFloat(position.supplied) * dailyRate * daysDeposited).toFixed(6) - const total = (parseFloat(position.supplied) + parseFloat(rewards)).toFixed(6) - - return { - protocol: config.protocol.id, - chainId: config.chainId, - token: config.token.symbol, - principal: position.supplied, - principalUSD: position.suppliedUSD, - rewards, - rewardsUSD: rewards, // 1:1 for stablecoins - total, - totalUSD: total, - apy: position.apy, - depositedAt: Date.now() - daysDeposited * 24 * 60 * 60 * 1000, // Mock - lastUpdateAt: Date.now() - } + protocolLib = getAaveInstance(config.chainId) } else if (config.protocol.id === 'spark') { - const spark = getSparkInstance(config.chainId) - if (!spark) return null - - const position = await spark.getUserPosition( - userAddress, - config.token.address, - config.token.decimals - ) + protocolLib = getSparkInstance(config.chainId) + } - if (parseFloat(position.supplied) === 0) return null - - const daysDeposited = 30 - const dailyRate = position.apy / 365 / 100 - const rewards = (parseFloat(position.supplied) * dailyRate * daysDeposited).toFixed(6) - const total = (parseFloat(position.supplied) + parseFloat(rewards)).toFixed(6) - - return { - protocol: config.protocol.id, - chainId: config.chainId, - token: config.token.symbol, - principal: position.supplied, - principalUSD: position.suppliedUSD, - rewards, - rewardsUSD: rewards, - total, - totalUSD: total, - apy: position.apy, - depositedAt: Date.now() - daysDeposited * 24 * 60 * 60 * 1000, - lastUpdateAt: Date.now() - } + if (!protocolLib) { + console.warn(`No protocol instance for ${config.protocol.id}`) + return null } - return null - */ + const position = await protocolLib.getUserPosition( + userAddress, + config.token.address, + config.token.decimals + ) + + // Only return if user has a balance + if (parseFloat(position.supplied) === 0) return null + + // Calculate rewards (simplified - in production use actual on-chain rewards) + const daysDeposited = 30 // Mock estimate + const dailyRate = position.apy / 365 / 100 + const rewards = (parseFloat(position.supplied) * dailyRate * daysDeposited).toFixed(6) + const total = (parseFloat(position.supplied) + parseFloat(rewards)).toFixed(6) + + return { + protocol: config.protocol.id, + chainId: config.chainId, + token: config.token.symbol, + principal: position.supplied, + principalUSD: position.suppliedUSD, + rewards, + rewardsUSD: rewards, // 1:1 for stablecoins + total, + totalUSD: total, + apy: position.apy, + depositedAt: Date.now() - daysDeposited * 24 * 60 * 60 * 1000, // Mock + lastUpdateAt: Date.now() + } } catch (error) { console.error('Error fetching user position:', error) return null diff --git a/advanced/wallets/react-wallet-v2/src/utils/EarnTransactionService.ts b/advanced/wallets/react-wallet-v2/src/utils/EarnTransactionService.ts new file mode 100644 index 000000000..794f4709b --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/utils/EarnTransactionService.ts @@ -0,0 +1,171 @@ +/** + * Earn Transaction Service + * Handles sending transactions for deposit, approval, and withdrawal operations + */ + +import { providers } from 'ethers' +import SettingsStore from '@/store/SettingsStore' +import { EIP155_CHAINS, TEIP155Chain } from '@/data/EIP155Data' +import { getWallet } from '@/utils/EIP155WalletUtil' +import { + buildApprovalTransaction, + buildDepositTransaction, + buildWithdrawTransaction +} from '@/utils/EarnService' +import { ProtocolConfig } from '@/types/earn' + +export interface TransactionResult { + success: boolean + txHash?: string + error?: string +} + +/** + * Send approval transaction for ERC20 token + */ +export async function sendApprovalTransaction( + config: ProtocolConfig, + amount: string, + userAddress: string +): Promise { + try { + const chainId = `eip155:${config.chainId}` + const wallet = await getWallet({ chainId }) + + // Build the approval transaction + const txData = buildApprovalTransaction(config, amount) + + // Get provider for the chain + const provider = new providers.JsonRpcProvider(EIP155_CHAINS[chainId as TEIP155Chain].rpc) + + // Connect wallet to provider + const connectedWallet = wallet.connect(provider) + + // Send transaction + const tx = await connectedWallet.sendTransaction({ + to: txData.to, + data: txData.data, + value: txData.value + }) + + console.log(`Approval transaction sent: ${tx.hash}`) + + // Wait for confirmation + const receipt = await tx.wait() + + console.log(`Approval transaction confirmed in block ${receipt.blockNumber}`) + + return { + success: true, + txHash: tx.hash + } + } catch (error: any) { + console.error('Error sending approval transaction:', error) + return { + success: false, + error: error.message || 'Transaction failed' + } + } +} + +/** + * Send deposit transaction + */ +export async function sendDepositTransaction( + config: ProtocolConfig, + amount: string, + userAddress: string +): Promise { + try { + const chainId = `eip155:${config.chainId}` + const wallet = await getWallet({ chainId }) + + // Build the deposit transaction + const txData = buildDepositTransaction(config, amount, userAddress) + + // Get provider for the chain + const provider = new providers.JsonRpcProvider(EIP155_CHAINS[chainId as TEIP155Chain].rpc) + + // Connect wallet to provider + const connectedWallet = wallet.connect(provider) + + // Send transaction + const tx = await connectedWallet.sendTransaction({ + to: txData.to, + data: txData.data, + value: txData.value + }) + + console.log(`Deposit transaction sent: ${tx.hash}`) + + // Wait for confirmation + const receipt = await tx.wait() + + console.log(`Deposit transaction confirmed in block ${receipt.blockNumber}`) + + return { + success: true, + txHash: tx.hash + } + } catch (error: any) { + console.error('Error sending deposit transaction:', error) + return { + success: false, + error: error.message || 'Transaction failed' + } + } +} + +/** + * Send withdrawal transaction + */ +export async function sendWithdrawTransaction( + config: ProtocolConfig, + amount: string, + userAddress: string +): Promise { + try { + const chainId = `eip155:${config.chainId}` + const wallet = await getWallet({ chainId }) + + // Build the withdrawal transaction + const txData = buildWithdrawTransaction(config, amount, userAddress) + + // Get provider for the chain + const provider = new providers.JsonRpcProvider(EIP155_CHAINS[chainId as TEIP155Chain].rpc) + + // Connect wallet to provider + const connectedWallet = wallet.connect(provider) + + // Send transaction + const tx = await connectedWallet.sendTransaction({ + to: txData.to, + data: txData.data, + value: txData.value + }) + + console.log(`Withdrawal transaction sent: ${tx.hash}`) + + // Wait for confirmation + const receipt = await tx.wait() + + console.log(`Withdrawal transaction confirmed in block ${receipt.blockNumber}`) + + return { + success: true, + txHash: tx.hash + } + } catch (error: any) { + console.error('Error sending withdrawal transaction:', error) + return { + success: false, + error: error.message || 'Transaction failed' + } + } +} + +export default { + sendApprovalTransaction, + sendDepositTransaction, + sendWithdrawTransaction +} From 70f126b1345f598cbfb64d5caaad62014ce45833 Mon Sep 17 00:00:00 2001 From: Gancho Radkov Date: Wed, 5 Nov 2025 11:56:48 +0200 Subject: [PATCH 5/7] feat: deposit - withdrawal --- .../src/components/Earn/AmountInput.tsx | 6 +- .../src/components/Earn/PositionCard.tsx | 4 +- .../react-wallet-v2/src/hooks/useEarnData.ts | 8 + .../react-wallet-v2/src/lib/AaveLib.ts | 17 ++- .../react-wallet-v2/src/pages/earn.tsx | 141 ++++++++++-------- .../react-wallet-v2/src/utils/EarnService.ts | 48 +++--- .../src/utils/EarnTransactionService.ts | 121 +++++++++++++-- 7 files changed, 247 insertions(+), 98 deletions(-) diff --git a/advanced/wallets/react-wallet-v2/src/components/Earn/AmountInput.tsx b/advanced/wallets/react-wallet-v2/src/components/Earn/AmountInput.tsx index e56ad2336..68439e3f0 100644 --- a/advanced/wallets/react-wallet-v2/src/components/Earn/AmountInput.tsx +++ b/advanced/wallets/react-wallet-v2/src/components/Earn/AmountInput.tsx @@ -41,12 +41,12 @@ export default function AmountInput({ }, [disabled, balance]) const formattedBalance = useMemo(() => { - if (!balance || balance === '0') return '0' + if (!balance || balance === '0') return '0.00' const num = parseFloat(balance) - if (isNaN(num)) return '0' + if (isNaN(num)) return '0.00' return num.toLocaleString('en-US', { minimumFractionDigits: 2, - maximumFractionDigits: 6 + maximumFractionDigits: 2 }) }, [balance]) diff --git a/advanced/wallets/react-wallet-v2/src/components/Earn/PositionCard.tsx b/advanced/wallets/react-wallet-v2/src/components/Earn/PositionCard.tsx index 7b6e6dd61..f224f94d3 100644 --- a/advanced/wallets/react-wallet-v2/src/components/Earn/PositionCard.tsx +++ b/advanced/wallets/react-wallet-v2/src/components/Earn/PositionCard.tsx @@ -26,7 +26,7 @@ const StyledText = styled(Text, { interface PositionCardProps { position: UserPosition - onWithdraw: (position: UserPosition) => void + onWithdraw: (position: UserPosition, amount: string) => void } export default function PositionCard({ position, onWithdraw }: PositionCardProps) { @@ -70,7 +70,7 @@ export default function PositionCard({ position, onWithdraw }: PositionCardProps } const handleWithdrawClick = () => { - onWithdraw(position) + onWithdraw(position, withdrawAmount) } return ( diff --git a/advanced/wallets/react-wallet-v2/src/hooks/useEarnData.ts b/advanced/wallets/react-wallet-v2/src/hooks/useEarnData.ts index 6ca04ec95..3bcaec860 100644 --- a/advanced/wallets/react-wallet-v2/src/hooks/useEarnData.ts +++ b/advanced/wallets/react-wallet-v2/src/hooks/useEarnData.ts @@ -41,10 +41,18 @@ export function useEarnData() { const refreshPositions = useCallback(async () => { if (!eip155Address) return + // Prevent multiple simultaneous calls + if (EarnStore.state.positionsLoading) { + console.log('Already loading positions, skipping...') + return + } + try { EarnStore.setPositionsLoading(true) + console.log('Fetching positions for:', eip155Address, 'chain:', selectedChainId) const positions = await getAllUserPositions(eip155Address, selectedChainId) EarnStore.setPositions(positions) + console.log('Positions loaded:', positions.length) } catch (error) { console.error('Error refreshing positions:', error) } finally { diff --git a/advanced/wallets/react-wallet-v2/src/lib/AaveLib.ts b/advanced/wallets/react-wallet-v2/src/lib/AaveLib.ts index 661f5e405..a1837cce8 100644 --- a/advanced/wallets/react-wallet-v2/src/lib/AaveLib.ts +++ b/advanced/wallets/react-wallet-v2/src/lib/AaveLib.ts @@ -63,14 +63,18 @@ export class AaveLib { try { const data = await this.poolContract.getReserveData(tokenAddress) + console.log('Raw reserve data array length:', data.length) + console.log('aTokenAddress from data[8]:', data[8]) + // currentLiquidityRate is in Ray units (1e27) - // Convert to APY percentage - const liquidityRate = data.currentLiquidityRate + // The aToken address is at index 8, not 7 + const liquidityRate = data.currentLiquidityRate || data[2] + const aTokenAddress = data[8] // Correct index for aToken address const apy = this.rayToAPY(liquidityRate) return { liquidityRate: liquidityRate.toString(), - aTokenAddress: data.aTokenAddress, + aTokenAddress: aTokenAddress, availableLiquidity: '0', // Would need additional call to get this totalSupply: '0' // Would need additional call to get this } @@ -89,14 +93,21 @@ export class AaveLib { tokenDecimals: number = 6 ): Promise { try { + console.log('Getting user position for:', { userAddress, tokenAddress }) + const reserveData = await this.getReserveData(tokenAddress) + console.log('Reserve data aToken address:', reserveData.aTokenAddress) + const aTokenContract = new ethers.Contract( reserveData.aTokenAddress, ATOKEN_ABI, this.provider ) + console.log('Calling balanceOf on aToken...') const balance = await aTokenContract.balanceOf(userAddress) + console.log('Balance:', balance.toString()) + const supplied = ethers.utils.formatUnits(balance, tokenDecimals) // For mock, use 1 USDC = 1 USD diff --git a/advanced/wallets/react-wallet-v2/src/pages/earn.tsx b/advanced/wallets/react-wallet-v2/src/pages/earn.tsx index a84218374..993997cea 100644 --- a/advanced/wallets/react-wallet-v2/src/pages/earn.tsx +++ b/advanced/wallets/react-wallet-v2/src/pages/earn.tsx @@ -69,42 +69,37 @@ export default function EarnPage() { return `${address.slice(0, 6)}...${address.slice(-4)}` } - // Mock balance for demo - will be replaced with real balance - const [mockBalance] = useState('5000') + // Real balance state const [realBalance, setRealBalance] = useState('0') const [isLoadingBalance, setIsLoadingBalance] = useState(false) - // Mock positions for UI testing - const [mockPositions] = useState([ - { - protocol: 'aave', - chainId: 8453, - token: 'USDC', - principal: '1000.00', - principalUSD: '1,000.00', - rewards: '12.50', - rewardsUSD: '12.50', - total: '1012.50', - totalUSD: '1,012.50', - apy: 4.35, - depositedAt: Date.now() - 30 * 24 * 60 * 60 * 1000, // 30 days ago - lastUpdateAt: Date.now() - }, - { - protocol: 'spark', - chainId: 8453, - token: 'USDC', - principal: '500.00', - principalUSD: '500.00', - rewards: '8.20', - rewardsUSD: '8.20', - total: '508.20', - totalUSD: '508.20', - apy: 4.82, - depositedAt: Date.now() - 15 * 24 * 60 * 60 * 1000, // 15 days ago - lastUpdateAt: Date.now() + // Fetch real balance when protocol or address changes + useEffect(() => { + if (earnState.selectedProtocol && eip155Address) { + setIsLoadingBalance(true) + refreshBalance() + .then(balance => { + setRealBalance(balance) + setIsLoadingBalance(false) + }) + .catch(error => { + console.error('Error fetching balance:', error) + setRealBalance('0') + setIsLoadingBalance(false) + }) + } else { + setRealBalance('0') } - ]) + }, [earnState.selectedProtocol, eip155Address, refreshBalance]) + + // Fetch positions when address changes or when active tab is 'positions' + // Disabled to prevent RPC spam - positions will be fetched after successful deposits + // useEffect(() => { + // if (earnState.activeTab === 'positions' && eip155Address) { + // console.log('Fetching positions for address:', eip155Address) + // refreshPositions() + // } + // }, [earnState.activeTab, eip155Address, refreshPositions]) // Fetch real balance when protocol changes // Disabled to prevent RPC spam - using mock balance instead @@ -150,8 +145,7 @@ export default function EarnPage() { return } - const balance = parseFloat(realBalance) > 0 ? realBalance : mockBalance - if (parseFloat(earnState.depositAmount) > parseFloat(balance)) { + if (parseFloat(earnState.depositAmount) > parseFloat(realBalance)) { styledToast('Insufficient balance', 'error') return } @@ -217,7 +211,7 @@ export default function EarnPage() { } } - const handleWithdraw = async (position: UserPosition) => { + const handleWithdraw = async (position: UserPosition, amount: string) => { if (!eip155Address) { styledToast('Please ensure your wallet is connected', 'error') return @@ -232,24 +226,30 @@ export default function EarnPage() { return } + if (!amount || parseFloat(amount) <= 0) { + styledToast('Please enter a valid amount to withdraw', 'error') + return + } + + if (parseFloat(amount) > parseFloat(position.total)) { + styledToast('Withdrawal amount exceeds available balance', 'error') + return + } + try { EarnStore.setTransactionStatus('withdrawing') EarnStore.setLoading(true) styledToast('Withdrawing funds...', 'default') - const withdrawResult = await sendWithdrawTransaction( - config, - position.total, // Withdraw all - eip155Address - ) + const withdrawResult = await sendWithdrawTransaction(config, amount, eip155Address) if (!withdrawResult.success) { throw new Error(withdrawResult.error || 'Withdrawal failed') } EarnStore.setTransactionStatus('success', withdrawResult.txHash) - styledToast(`Successfully withdrew ${position.total} ${position.token}!`, 'success') + styledToast(`Successfully withdrew ${amount} ${position.token}!`, 'success') // Refresh data refreshBalance() @@ -389,7 +389,7 @@ export default function EarnPage() { 0 ? realBalance : mockBalance} + balance={realBalance} tokenSymbol={earnState.selectedProtocol.token.symbol} label="Amount to Deposit" placeholder="0.00" @@ -471,7 +471,7 @@ export default function EarnPage() { earnState.loading || !earnState.depositAmount || parseFloat(earnState.depositAmount) <= 0 || - parseFloat(earnState.depositAmount) > parseFloat(mockBalance) + parseFloat(earnState.depositAmount) > parseFloat(realBalance) } onClick={handleDeposit} > @@ -513,26 +513,47 @@ export default function EarnPage() { {/* My Positions Tab Content */} {earnState.activeTab === 'positions' && ( - - Your Active Positions - + + + Your Active Positions + + + {earnState.positionsLoading ? ( - + Loading positions... - ) : mockPositions.length > 0 ? ( - mockPositions.map((position, index) => ( + ) : earnState.positions.length > 0 ? ( + earnState.positions.map((position, index) => ( No active positions - Start earning by depositing your assets in the Earn tab + {earnState.positions.length === 0 && !earnState.positionsLoading + ? 'Click "Refresh" to load your positions, or start earning by depositing in the Earn tab' + : 'Start earning by depositing your assets in the Earn tab'}