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..e0d56e761 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/public/icons/earn-icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + 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..68439e3f0 --- /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.00' + const num = parseFloat(balance) + if (isNaN(num)) return '0.00' + return num.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }) + }, [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..f224f94d3 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/components/Earn/PositionCard.tsx @@ -0,0 +1,210 @@ +import { Card, Row, Col, Text, Button, styled, Input, Divider } from '@nextui-org/react' +import { UserPosition } from '@/types/earn' +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: '2px 8px', + borderRadius: '4px', + fontSize: '16px', + fontWeight: '500', + backgroundColor: 'rgba(34, 197, 94, 0.1)', + color: 'rgb(34, 197, 94)' +} as any) + +const StyledCard = styled(Card, { + padding: '0px', + marginBottom: '12px', + border: '1px solid rgba(255, 255, 255, 0.1)' +} as any) + +const StyledText = styled(Text, { + fontWeight: 400 +} as any) + +interface PositionCardProps { + position: UserPosition + onWithdraw: (position: UserPosition, amount: string) => void +} + +export default function PositionCard({ position, onWithdraw }: PositionCardProps) { + 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) + 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]) + + 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, withdrawAmount) + } + + return ( + + {/* Header Row: Protocol Name and APY */} + + + {protocol?.displayName || position.protocol} + + {position.apy.toFixed(2)}% APY + + + {/* Second Row: Token and Chain */} + + + {position.token} • Base + + + + {/* Third Row: Deposit Amount (left) and Rewards (right) */} + + + + Deposit Amount + + + {parseFloat(position.principal).toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 2 + })}{' '} + {position.token} + + + + + + Rewards Earned + + + + + {parseFloat(position.rewards).toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 6 + })}{' '} + {position.token} + + + + + {/* 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/components/Earn/ProtocolCard.tsx b/advanced/wallets/react-wallet-v2/src/components/Earn/ProtocolCard.tsx new file mode 100644 index 000000000..b76a12dd0 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/components/Earn/ProtocolCard.tsx @@ -0,0 +1,140 @@ +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', { + 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', + borderRadius: '4px', + fontSize: '11px', + fontWeight: '500', + variants: { + color: { + success: { + color: 'rgb(34, 197, 94)' + }, + warning: { + color: 'rgb(251, 191, 36)' + }, + error: { + color: 'rgb(239, 68, 68)' + }, + primary: { + color: 'rgb(99, 102, 241)' + }, + secondary: { + color: 'rgb(168, 85, 247)' + }, + default: { + 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 { apyData, tvlData } = useSnapshot(EarnStore.state) + + // Get live APY from store, fallback to config APY + const displayAPY = apyData[`${config.protocol.id}-${config.chainId}`] ?? config.apy + + // Get live TVL from store, fallback to config TVL + const displayTVL = tvlData[`${config.protocol.id}-${config.chainId}`] + const formattedTVL = displayTVL + ? `$${parseFloat(displayTVL).toLocaleString('en-US', { + maximumFractionDigits: 1, + notation: 'compact', + compactDisplay: 'short' + })}` + : config.tvl + + 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} + + {displayAPY.toFixed(2)}% APY +
+ + {/* Second Row: Token and Chain */} +
+ + {config.token.symbol} • {config.chainName} + +
+ + {/* Details */} +
+
+
+ + TVL: {formattedTVL} + +
+ 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..59c55c80d --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/data/EarnProtocolsData.ts @@ -0,0 +1,178 @@ +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 + } + }, + // 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', + poolDataProvider: '0x2d8A3C5677189723C4cB8873CfC9C8976FDF38Ac' + }, + 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..3db550570 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/hooks/useEarnData.ts @@ -0,0 +1,130 @@ +/** + * 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, + fetchAllProtocolTVLs, + 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 { + console.log('Refreshing APYs for chain', selectedChainId) + const apyMap = await fetchAllProtocolAPYs(selectedChainId) + + // 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) + } + }, [selectedChainId]) + + /** + * Fetch and update protocol TVLs + */ + const refreshTVLs = useCallback(async () => { + if (!selectedChainId) return + + try { + console.log('Refreshing TVLs for chain', selectedChainId) + const tvlMap = await fetchAllProtocolTVLs(selectedChainId) + + // Store TVLs in the store + tvlMap.forEach((tvl, key) => { + const [protocolId, chainIdStr] = key.split('-') + EarnStore.setTVL(protocolId, parseInt(chainIdStr), tvl) + }) + + console.log('TVL refresh complete:', Array.from(tvlMap.entries())) + } catch (error) { + console.error('Error refreshing TVLs:', error) + } + }, [selectedChainId]) + + /** + * Fetch and update user positions + */ + 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 { + EarnStore.setPositionsLoading(false) + } + }, [eip155Address, selectedChainId]) + + /** + * Fetch token balance for selected protocol + */ + const refreshBalance = useCallback( + async (skipCache: boolean = false) => { + if (!selectedProtocol || !eip155Address) return '0' + + try { + const balance = await getUserTokenBalance(selectedProtocol, eip155Address, skipCache) + 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(), refreshTVLs(), refreshPositions()]) + }, [refreshAPYs, refreshTVLs, refreshPositions]) + + // Auto-fetch APYs and TVLs on mount (with 2-minute cache, safe from spam) + useEffect(() => { + refreshAPYs() + refreshTVLs() + }, [refreshAPYs, refreshTVLs]) + + return { + refreshAPYs, + refreshTVLs, + 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..b20c87ef6 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/lib/AaveLib.ts @@ -0,0 +1,292 @@ +/** + * 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)', + 'function totalSupply() 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) + + console.log('Raw reserve data array length:', data.length) + console.log('aTokenAddress from data[8]:', data[8]) + + // currentLiquidityRate is in Ray units (1e27) + // 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: 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 { + 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 + 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 + } + } + + /** + * Get Total Value Locked (TVL) for a specific asset + * TVL = total supply of aTokens (which represents all deposits) + */ + async getTVL(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 totalSupply = await aTokenContract.totalSupply() + const tvl = ethers.utils.formatUnits(totalSupply, tokenDecimals) + + return tvl + } catch (error) { + console.error('Error fetching TVL:', 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..64fa2f19d --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/lib/SparkLib.ts @@ -0,0 +1,263 @@ +/** + * 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)', + 'function totalSupply() 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 + } + } + + /** + * Get Total Value Locked (TVL) for a specific asset + * TVL = total supply of spTokens (which represents all deposits) + */ + async getTVL(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 totalSupply = await spTokenContract.totalSupply() + const tvl = ethers.utils.formatUnits(totalSupply, tokenDecimals) + + return tvl + } catch (error) { + console.error('Error fetching TVL:', 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..5d0880645 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/pages/earn.tsx @@ -0,0 +1,606 @@ +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' +import { checkApprovalNeeded, clearBalanceCache } from '@/utils/EarnService' +import { + sendApprovalTransaction, + sendDepositTransaction, + sendWithdrawTransaction +} from '@/utils/EarnTransactionService' +import { eip155Addresses } from '@/utils/EIP155WalletUtil' + +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, 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)}` + } + + // Real balance state + const [realBalance, setRealBalance] = useState('0') + const [isLoadingBalance, setIsLoadingBalance] = useState(false) + + // 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 + // 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(() => { + 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('') + } + + const handleAmountChange = (amount: string) => { + EarnStore.setDepositAmount(amount) + } + + const handleDeposit = async () => { + if (!earnState.selectedProtocol || !eip155Address) { + styledToast('Please select a protocol and ensure your wallet is connected', 'error') + return + } + + if (!earnState.depositAmount || parseFloat(earnState.depositAmount) <= 0) { + styledToast('Please enter a valid amount', 'error') + return + } + + if (parseFloat(earnState.depositAmount) > parseFloat(realBalance)) { + styledToast('Insufficient balance', 'error') + return + } + + 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' + ) + + // Clear balance cache to force refresh + clearBalanceCache() + + // Reset form and refresh data + EarnStore.resetDepositForm() + + // Force refresh balance (skip cache) + const newBalance = await refreshBalance(true) + setRealBalance(newBalance) + + 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, amount: string) => { + 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 + } + + 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, amount, eip155Address) + + if (!withdrawResult.success) { + throw new Error(withdrawResult.error || 'Withdrawal failed') + } + + EarnStore.setTransactionStatus('success', withdrawResult.txHash) + styledToast(`Successfully withdrew ${amount} ${position.token}!`, 'success') + + // Clear balance cache to force refresh + clearBalanceCache() + + // Force refresh balance (skip cache) + const newBalance = await refreshBalance(true) + setRealBalance(newBalance) + + // Refresh positions + 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(() => { + if ( + !earnState.selectedProtocol || + !earnState.depositAmount || + parseFloat(earnState.depositAmount) <= 0 + ) { + return null + } + + const amount = parseFloat(earnState.depositAmount) + + // Use live APY from store if available, otherwise use config APY + const liveAPY = + earnState.apyData[ + `${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.apyData]) + + return ( + + + +
+ Account: + +
+
+
+ + {/* 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 && ( + + + + + + {/* 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 + + {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'} + + + + )} + + )} + + {/* Footer */} + + Powered by WalletConnect + +
+
+ ) +} 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..824ba0d44 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/store/EarnStore.ts @@ -0,0 +1,219 @@ +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' + + // Simple transaction status for UI + transactionStatus: 'idle' | 'approving' | 'depositing' | 'withdrawing' | 'success' | 'error' + transactionHash: string | null + loading: boolean + + // APY and TVL data (using Record for Valtio reactivity) + apyData: Record + tvlData: Record +} + +/** + * 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', + + transactionStatus: 'idle', + transactionHash: null, + loading: false, + + apyData: {}, + tvlData: {} +}) + +/** + * 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() + }, + + // 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.apyData[key] = apy + }, + + getAPY(protocolId: string, chainId: number): number | undefined { + const key = `${protocolId}-${chainId}` + return state.apyData[key] + }, + + // TVL management + setTVL(protocolId: string, chainId: number, tvl: string) { + const key = `${protocolId}-${chainId}` + state.tvlData[key] = tvl + }, + + getTVL(protocolId: string, chainId: number): string | undefined { + const key = `${protocolId}-${chainId}` + return state.tvlData[key] + }, + + // Reset entire store + reset() { + state.selectedProtocol = null + state.selectedChainId = 8453 + state.positions = [] + state.positionsLoading = false + state.depositAmount = '' + state.withdrawAmount = '' + state.activeTab = 'earn' + state.transactionStatus = 'idle' + state.transactionHash = null + state.loading = false + state.apyData = {} + state.tvlData = {} + 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..a03e1d657 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/utils/EarnService.ts @@ -0,0 +1,636 @@ +/** + * 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' +import { BigNumber } from 'ethers' + +// Cache for protocol instances +const protocolInstances: { + aave: Map + spark: Map +} = { + aave: new Map(), + spark: new Map() +} + +// APY Cache with timestamps +const apyCache: Map = new Map() +const APY_CACHE_DURATION = 2 * 60 * 1000 // 2 minutes in milliseconds + +// TVL Cache with timestamps +const tvlCache: Map = new Map() +const TVL_CACHE_DURATION = 2 * 60 * 1000 // 2 minutes in milliseconds + +// Balance Cache with timestamps +const balanceCache: Map = new Map() +const BALANCE_CACHE_DURATION = 30 * 1000 // 30 seconds in milliseconds + +// In-flight promise tracking to prevent duplicate calls +const inFlightAPYPromises: Map> = new Map() +const inFlightTVLPromises: Map> = new Map() +const inFlightBalancePromises: Map> = 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 { + 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 + } + + // Check if there's already a fetch in progress for this key + const inFlightPromise = inFlightAPYPromises.get(cacheKey) + if (inFlightPromise) { + console.log(`Awaiting in-flight APY request for ${config.protocol.name}...`) + return inFlightPromise + } + + // Create new fetch promise + const fetchPromise = (async () => { + try { + let protocolLib: AaveLib | SparkLib | null = null + + 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 + } finally { + // Remove from in-flight map when done + inFlightAPYPromises.delete(cacheKey) + } + })() + + // Store the promise so other calls can await it + inFlightAPYPromises.set(cacheKey, fetchPromise) + + return fetchPromise +} + +/** + * 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 +} + +/** + * Fetch TVL for a protocol + */ +export async function fetchProtocolTVL(config: ProtocolConfig): Promise { + const cacheKey = `${config.protocol.id}-${config.chainId}` + + // Check cache first + const cached = tvlCache.get(cacheKey) + if (cached && Date.now() - cached.timestamp < TVL_CACHE_DURATION) { + console.log( + `Using cached TVL for ${config.protocol.name} (${Math.floor( + (Date.now() - cached.timestamp) / 1000 + )}s old)` + ) + return cached.tvl + } + + // Check if there's already a fetch in progress for this key + const inFlightPromise = inFlightTVLPromises.get(cacheKey) + if (inFlightPromise) { + console.log(`Awaiting in-flight TVL request for ${config.protocol.name}...`) + return inFlightPromise + } + + // Create new fetch promise + const fetchPromise = (async () => { + try { + let protocolLib: AaveLib | SparkLib | null = null + + 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.tvl // Fallback to configured value + } + + console.log(`Fetching live TVL for ${config.protocol.name} on chain ${config.chainId}...`) + + const tvl = await protocolLib.getTVL(config.token.address, config.token.decimals) + + // Cache the result + tvlCache.set(cacheKey, { tvl, timestamp: Date.now() }) + console.log(`✓ Fetched TVL for ${config.protocol.name}: $${parseFloat(tvl).toLocaleString()}`) + + return tvl + } catch (error: any) { + // Handle errors gracefully + if (error.code === 'CALL_EXCEPTION') { + console.warn( + `Contract call failed for ${config.protocol.name} on chain ${config.chainId} - using fallback TVL` + ) + } else if (error.message?.includes('429') || error.message?.includes('Too Many Requests')) { + console.warn( + `Rate limited when fetching TVL for ${config.protocol.name} - using fallback TVL` + ) + } else { + console.error(`Error fetching TVL for ${config.protocol.name}:`, error) + } + return config.tvl // Fallback to config value + } finally { + // Remove from in-flight map when done + inFlightTVLPromises.delete(cacheKey) + } + })() + + // Store the promise so other calls can await it + inFlightTVLPromises.set(cacheKey, fetchPromise) + + return fetchPromise +} + +/** + * Fetch all protocol TVLs + */ +export async function fetchAllProtocolTVLs(chainId: number): Promise> { + const configs = PROTOCOL_CONFIGS.filter(c => c.chainId === chainId) + const tvlMap = new Map() + + const promises = configs.map(async config => { + const tvl = await fetchProtocolTVL(config) + tvlMap.set(`${config.protocol.id}-${config.chainId}`, tvl) + }) + + await Promise.allSettled(promises) + return tvlMap +} + +/** + * Get user's token balance (with caching) + */ +export async function getUserTokenBalance( + config: ProtocolConfig, + userAddress: string, + skipCache: boolean = false +): Promise { + const cacheKey = `${config.protocol.id}-${config.chainId}-${config.token.address}-${userAddress}` + + // Check cache first (unless skipCache is true) + if (!skipCache) { + const cached = balanceCache.get(cacheKey) + if (cached && Date.now() - cached.timestamp < BALANCE_CACHE_DURATION) { + console.log( + `Using cached balance for ${config.token.symbol} (${Math.floor( + (Date.now() - cached.timestamp) / 1000 + )}s old)` + ) + return cached.balance + } + } + + // Check if there's already a fetch in progress for this key + const inFlightPromise = inFlightBalancePromises.get(cacheKey) + if (inFlightPromise) { + console.log(`Awaiting in-flight balance request for ${config.token.symbol}...`) + return inFlightPromise + } + + // Create new fetch promise + const fetchPromise = (async () => { + try { + let protocolLib: AaveLib | SparkLib | null = null + + 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}`) + return '0' + } + + console.log(`Fetching balance for ${config.token.symbol}...`) + const balance = await protocolLib.getTokenBalance(config.token.address, userAddress) + + // Cache the result + balanceCache.set(cacheKey, { balance, timestamp: Date.now() }) + console.log(`✓ Fetched balance: ${balance} ${config.token.symbol}`) + + return balance + } catch (error) { + console.error('Error fetching token balance:', error) + return '0' + } finally { + // Remove from in-flight map when done + inFlightBalancePromises.delete(cacheKey) + } + })() + + // Store the promise so other calls can await it + inFlightBalancePromises.set(cacheKey, fetchPromise) + + return fetchPromise +} + +/** + * Clear balance cache (call after transactions) + */ +export function clearBalanceCache() { + balanceCache.clear() + console.log('Balance cache cleared') +} + +/** + * Get user's position in a protocol + */ +export async function getUserProtocolPosition( + config: ProtocolConfig, + userAddress: string +): Promise { + try { + let protocolLib: AaveLib | SparkLib | null = null + + 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}`) + 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 + + // Use the APY from the cache if available, otherwise use the position's APY + const { default: EarnStore } = await import('@/store/EarnStore') + const cachedAPY = EarnStore.getAPY(config.protocol.id, config.chainId) + const apy = cachedAPY || position.apy || config.apy + + // In Aave, the aToken balance (position.supplied) already includes accrued interest + // We'll estimate the original principal based on a 30-day deposit assumption + // This is a rough estimate - in production, you'd track the actual deposit amount + const daysDeposited = 30 // Mock estimate + const totalWithInterest = parseFloat(position.supplied) + const estimatedPrincipal = totalWithInterest / (1 + (apy / 100 / 365) * daysDeposited) + const estimatedRewards = totalWithInterest - estimatedPrincipal + + return { + protocol: config.protocol.id, + chainId: config.chainId, + token: config.token.symbol, + principal: estimatedPrincipal.toFixed(6), + principalUSD: estimatedPrincipal.toFixed(6), + rewards: estimatedRewards.toFixed(6), + rewardsUSD: estimatedRewards.toFixed(6), // 1:1 for stablecoins + total: position.supplied, // This is the actual withdrawable amount + totalUSD: position.supplied, + apy: apy, + depositedAt: Date.now() - daysDeposited * 24 * 60 * 60 * 1000, // Mock + lastUpdateAt: Date.now() + } + } 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[] = [] + + // Fetch positions sequentially to avoid rate limiting + for (const config of configs) { + try { + const position = await getUserProtocolPosition(config, userAddress) + if (position) { + positions.push(position) + } + // Add a small delay between requests to avoid rate limiting + await new Promise(resolve => setTimeout(resolve, 500)) + } catch (error) { + console.error(`Error fetching position for ${config.protocol.name}:`, error) + // Continue with next protocol even if one fails + } + } + + 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 +} 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..88c01cdd6 --- /dev/null +++ b/advanced/wallets/react-wallet-v2/src/utils/EarnTransactionService.ts @@ -0,0 +1,260 @@ +/** + * Earn Transaction Service + * Handles sending transactions for deposit, approval, and withdrawal operations + */ + +import { providers, utils } from 'ethers' +import SettingsStore from '@/store/SettingsStore' +import { getWallet } from '@/utils/EIP155WalletUtil' +import { + buildApprovalTransaction, + buildDepositTransaction, + buildWithdrawTransaction +} from '@/utils/EarnService' +import { ProtocolConfig } from '@/types/earn' +import { EARN_CHAINS } from '@/data/EarnProtocolsData' + +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, address: userAddress }) + + // Build the approval transaction + const txData = buildApprovalTransaction(config, amount) + + // Get RPC URL for the chain from EARN_CHAINS + const rpcUrl = Object.values(EARN_CHAINS).find(c => c.id === config.chainId)?.rpc + if (!rpcUrl) { + throw new Error(`No RPC URL found for chain ${config.chainId}`) + } + + // Get provider for the chain + const provider = new providers.JsonRpcProvider(rpcUrl) + + // Connect wallet to provider + const connectedWallet = wallet.connect(provider) + + // Check ETH balance for gas + const ethBalance = await provider.getBalance(userAddress) + console.log('ETH Balance:', ethBalance.toString(), '(', utils.formatEther(ethBalance), 'ETH )') + + // Base network has very low gas fees - typically just a few cents + // 0.0001 ETH (~$0.30) should be enough for multiple transactions + const minBalance = utils.parseEther('0.0001') + if (ethBalance.lt(minBalance)) { + console.warn('Low ETH balance - may not have enough for gas fees') + return { + success: false, + error: `Insufficient ETH for gas fees. Your balance: ${utils.formatEther( + ethBalance + )} ETH. Please add at least 0.0005 ETH (~$1.50) to cover transaction fees on Base network.` + } + } + + console.log('Sending approval transaction...', { + to: txData.to, + from: userAddress, + data: txData.data + }) + + // Send transaction - let the wallet estimate gas automatically + // Base uses EIP-1559, so we don't need to set gasPrice + const tx = await connectedWallet.sendTransaction({ + to: txData.to, + data: txData.data, + value: txData.value || 0 + // No gasLimit or gasPrice - let ethers estimate for optimal fees + }) + + 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, address: userAddress }) + + // Build the deposit transaction + const txData = buildDepositTransaction(config, amount, userAddress) + + // Get RPC URL for the chain from EARN_CHAINS + const rpcUrl = Object.values(EARN_CHAINS).find(c => c.id === config.chainId)?.rpc + if (!rpcUrl) { + throw new Error(`No RPC URL found for chain ${config.chainId}`) + } + + // Get provider for the chain + const provider = new providers.JsonRpcProvider(rpcUrl) + + // Connect wallet to provider + const connectedWallet = wallet.connect(provider) + + // Log transaction details + console.log('Sending deposit transaction...', { + to: txData.to, + from: userAddress, + data: txData.data, + amount: amount, + token: config.token.address + }) + + // Check USDC balance before deposit + const tokenContract = new utils.Interface([ + 'function balanceOf(address) view returns (uint256)' + ]) + const balanceCallData = tokenContract.encodeFunctionData('balanceOf', [userAddress]) + + try { + const balanceResult = await provider.call({ + to: config.token.address, + data: balanceCallData + }) + const balance = utils.defaultAbiCoder.decode(['uint256'], balanceResult)[0] + console.log('USDC Balance:', utils.formatUnits(balance, config.token.decimals)) + + // Check if balance is sufficient + const amountBN = utils.parseUnits(amount, config.token.decimals) + if (balance.lt(amountBN)) { + return { + success: false, + error: `Insufficient USDC balance. You have ${utils.formatUnits( + balance, + config.token.decimals + )} USDC but trying to deposit ${amount} USDC.` + } + } + } catch (balanceError) { + console.warn('Could not check USDC balance:', balanceError) + } + + // Send transaction - let the wallet estimate gas automatically + const tx = await connectedWallet.sendTransaction({ + to: txData.to, + data: txData.data, + value: txData.value || 0 + // No gasLimit - let ethers estimate for optimal fees + }) + + 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, address: userAddress }) + + // Build the withdrawal transaction + const txData = buildWithdrawTransaction(config, amount, userAddress) + + // Get RPC URL for the chain from EARN_CHAINS + const rpcUrl = Object.values(EARN_CHAINS).find(c => c.id === config.chainId)?.rpc + if (!rpcUrl) { + throw new Error(`No RPC URL found for chain ${config.chainId}`) + } + + // Get provider for the chain + const provider = new providers.JsonRpcProvider(rpcUrl) + + // Connect wallet to provider + const connectedWallet = wallet.connect(provider) + + console.log('Sending withdrawal transaction...', { + to: txData.to, + from: userAddress, + data: txData.data + }) + + // Send transaction - let the wallet estimate gas automatically + const tx = await connectedWallet.sendTransaction({ + to: txData.to, + data: txData.data, + value: txData.value || 0 + // No gasLimit - let ethers estimate for optimal fees + }) + + 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 +}