diff --git a/src/apps/key-wallet/utils/blockchain.ts b/src/apps/key-wallet/utils/blockchain.ts index d9341baa..9fee661d 100644 --- a/src/apps/key-wallet/utils/blockchain.ts +++ b/src/apps/key-wallet/utils/blockchain.ts @@ -308,7 +308,7 @@ export const formatBalance = ( export const formatUsdValue = (value: number): string => { if (value === 0) return '$0.00'; if (value < 0.01) return '<$0.01'; - return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; }; export const shortenAddress = (address: string, chars: number = 4): string => { diff --git a/src/apps/pulse/components/App/HomeScreen.tsx b/src/apps/pulse/components/App/HomeScreen.tsx index 3acbc058..046bba43 100644 --- a/src/apps/pulse/components/App/HomeScreen.tsx +++ b/src/apps/pulse/components/App/HomeScreen.tsx @@ -130,7 +130,7 @@ export default function HomeScreen(props: HomeScreenProps) { const [maxStableCoinBalance, setMaxStableCoinBalance] = useState<{ chainId: number; balance: number; - price?: number; + tokenAmount: number; }>(); const [transactionData, setTransactionData] = useState<{ sellToken: SelectedToken | null; @@ -168,6 +168,8 @@ export default function HomeScreen(props: HomeScreenProps) { const [tokenAmount, setTokenAmount] = useState(''); const [isRefreshingHome, setIsRefreshingHome] = useState(false); const [usdAmount, setUsdAmount] = useState(''); + const [isMaxSelected, setIsMaxSelected] = useState(false); + const [maxTokenAmount, setMaxTokenAmount] = useState(); const [dispensableAssets, setDispensableAssets] = useState< DispensableAsset[] >([]); @@ -402,7 +404,6 @@ export default function HomeScreen(props: HomeScreenProps) { (key) => stableBalance[Number(key)].balance === maxStableBalance ) || '1' ); - // Set USDC price from the chain with max stable balance const usdcPriceForMaxChain = stableBalance[chainIdOfMaxStableBalance]?.price; @@ -413,6 +414,7 @@ export default function HomeScreen(props: HomeScreenProps) { setMaxStableCoinBalance({ chainId: chainIdOfMaxStableBalance, balance: maxStableBalance, + tokenAmount: stableBalance[chainIdOfMaxStableBalance]?.tokenAmount ?? 0, }); }, [portfolioTokens, walletPortfolioData]); @@ -1205,6 +1207,8 @@ export default function HomeScreen(props: HomeScreenProps) { userPortfolio={portfolioTokens} gasTankBalance={gasTankBalance} usdcPrice={usdcPrice} + isMaxSelected={isMaxSelected} + maxTokenAmount={maxTokenAmount} /> ); @@ -1361,7 +1365,11 @@ export default function HomeScreen(props: HomeScreenProps) { payingTokens={payingTokens} portfolioTokens={portfolioTokens} maxStableCoinBalance={ - maxStableCoinBalance ?? { chainId: 1, balance: 2 } + maxStableCoinBalance ?? { + chainId: 1, + balance: 2, + tokenAmount: 0, + } } customBuyAmounts={[...customBuyAmounts, 'MAX']} setPreviewBuy={setPreviewBuy} @@ -1374,6 +1382,8 @@ export default function HomeScreen(props: HomeScreenProps) { setChains={setChains} usdcPrice={usdcPrice} isRefreshing={isRefreshingHome} + setIsMaxSelected={setIsMaxSelected} + setMaxTokenAmount={setMaxTokenAmount} /> ) : ( >; @@ -84,6 +85,10 @@ interface BuyProps { setChains: Dispatch>; usdcPrice?: number; // For Relay Buy: USDC price from portfolio (passed from HomeScreen) isRefreshing?: boolean; + isMaxSelected?: boolean; // Whether MAX was selected + maxTokenAmount?: number; // Balance amount when MAX is selected + setIsMaxSelected?: Dispatch>; // Update parent MAX selected state + setMaxTokenAmount?: Dispatch>; // Update parent max token amount state } export default function Buy(props: BuyProps) { @@ -105,6 +110,10 @@ export default function Buy(props: BuyProps) { customBuyAmounts, usdcPrice, isRefreshing = false, + isMaxSelected = false, + maxTokenAmount, + setIsMaxSelected: setParentIsMaxSelected, + setMaxTokenAmount: setParentMaxTokenAmount, } = props; const [usdAmount, setUsdAmount] = useState(''); const [debouncedUsdAmount, setDebouncedUsdAmount] = useState(''); @@ -268,6 +277,8 @@ export default function Buy(props: BuyProps) { if (!input || !Number.isNaN(parseFloat(input))) { setInputPlaceholder('0.00'); setUsdAmount(input); + setParentIsMaxSelected?.(false); // Reset MAX flag when user manually types + setParentMaxTokenAmount?.(undefined); setBelowMinimumAmount(false); setNoEnoughLiquidity(false); setInsufficientWalletBalance(false); @@ -293,7 +304,56 @@ export default function Buy(props: BuyProps) { useEffect(() => { const timer = setTimeout(() => { - if (usdAmount && !Number.isNaN(parseFloat(usdAmount))) { + // Handle MAX case - use maxTokenAmount directly + if (isMaxSelected && maxTokenAmount && maxTokenAmount > 0) { + const amount = maxTokenAmount; + + if (amount < 2) { + setBelowMinimumAmount(true); + setNoEnoughLiquidity(false); + setInsufficientWalletBalance(false); + return; + } + + setBelowMinimumAmount(false); + setNoEnoughLiquidity(false); + setInsufficientWalletBalance(false); + // Pass the balance as a string for dispens able assets calculation + setDebouncedUsdAmount(maxTokenAmount.toString()); + const [dAssets, pChains, pTokens] = getDispensableAssets( + maxTokenAmount.toString(), + walletPortfolioData?.result.data, + maxStableCoinBalance.chainId + ); + + // For MAX selection, skip validation errors and let getBestOffer handle the full amount + if ( + pChains.length === 0 || + dAssets.length === 0 || + pTokens.length === 0 + ) { + if (!isMaxSelected) { + // Only show error for non-MAX selections + setNoEnoughLiquidity(true); + return; + } + // For MAX: proceed without dispensable assets validation + // getBestOffer will handle the full balance + setParentUsdAmount(maxTokenAmount.toString()); + return; + } + + // Always update payingTokens to ensure correct USD amounts are passed to PreviewBuy + setDispensableAssets(dAssets); + setPermittedChains(pChains); + setPayingTokens(pTokens); + setParentDispensableAssets(dAssets); + setParentUsdAmount(maxTokenAmount.toString()); + } else if ( + usdAmount && + usdAmount !== 'MAX' && + !Number.isNaN(parseFloat(usdAmount)) + ) { const amount = parseFloat(usdAmount); if (amount < 2) { @@ -335,6 +395,8 @@ export default function Buy(props: BuyProps) { }, [ sumOfStableBalance, usdAmount, + isMaxSelected, + maxTokenAmount, setPayingTokens, walletPortfolioData?.result.data, dispensableAssets.length, @@ -350,14 +412,19 @@ export default function Buy(props: BuyProps) { ) { setIsLoading(true); try { - // For Relay Buy with EXACT_INPUT, we pass the USD amount directly - // The quote will tell us how many tokens we'll receive + // For Relay Buy with EXACT_INPUT, we pass the USDC amount directly + // When MAX is selected, use maxTokenAmount to pass the balance directly + // Otherwise use the debouncedUsdAmount as USD amount that will be converted to USDC const offer = await getBestOffer({ fromAmount: debouncedUsdAmount, toTokenAddress: token.address, toChainId: token.chainId, fromChainId: maxStableCoinBalance.chainId, usdcPrice, + maxTokenAmount: + isMaxSelected && maxTokenAmount + ? maxTokenAmount.toString() + : undefined, }); setBuyOffer(offer); @@ -377,7 +444,10 @@ export default function Buy(props: BuyProps) { { operation: 'fetch_relay_buy_offer', buyToken: token.symbol, - amount: debouncedUsdAmount, + amount: + isMaxSelected && maxTokenAmount + ? maxTokenAmount.toString() + : debouncedUsdAmount, toChainId: token.chainId, fromChainId: maxStableCoinBalance.chainId, }, @@ -394,6 +464,8 @@ export default function Buy(props: BuyProps) { debouncedUsdAmount, token, isRelayInitialized, + isMaxSelected, + maxTokenAmount, getBestOffer, maxStableCoinBalance.chainId, usdcPrice, @@ -744,16 +816,22 @@ export default function Buy(props: BuyProps) {
- setInputPlaceholder('')} - data-testid="pulse-buy-amount-input" - /> + {isMaxSelected ? ( +
+ {maxStableCoinBalance.balance.toFixed(2)} +
+ ) : ( + setInputPlaceholder('')} + data-testid="pulse-buy-amount-input" + /> + )} USD @@ -860,9 +938,24 @@ export default function Buy(props: BuyProps) { onClick={() => { if (!isDisabled) { if (isMax) { - setUsdAmount(sumOfStableBalance.toFixed(2)); + // Use full balance for MAX display + const fullBalance = maxStableCoinBalance.tokenAmount; + const balanceStr = fullBalance.toString(); + setUsdAmount(balanceStr); // Store full balance for display + + // For API calls, calculate amount after 1% platform fee + const maxAmount = fullBalance * 0.99; + // Proper rounding: round down to be conservative with fee calculation + const roundedAmount = Math.floor(maxAmount * 100) / 100; + + // Update parent state for PreviewBuy + setParentIsMaxSelected?.(true); + setParentMaxTokenAmount?.(roundedAmount); } else { setUsdAmount(item); + // Reset parent state + setParentIsMaxSelected?.(false); + setParentMaxTokenAmount?.(undefined); } } }} diff --git a/src/apps/pulse/components/Buy/PreviewBuy.tsx b/src/apps/pulse/components/Buy/PreviewBuy.tsx index 41eb0a52..34847694 100644 --- a/src/apps/pulse/components/Buy/PreviewBuy.tsx +++ b/src/apps/pulse/components/Buy/PreviewBuy.tsx @@ -69,6 +69,8 @@ interface PreviewBuyProps { userPortfolio?: Token[]; // For Relay Buy: user's token portfolio gasTankBalance?: number; // For Relay Buy: gas tank balance to validate transaction usdcPrice?: number; // For Relay Buy: USDC price in USD (e.g., 0.9998) + isMaxSelected?: boolean; // For Relay Buy: whether MAX was selected + maxTokenAmount?: number; // For Relay Buy: actual balance amount when MAX is selected } export default function PreviewBuy(props: PreviewBuyProps) { @@ -87,6 +89,8 @@ export default function PreviewBuy(props: PreviewBuyProps) { userPortfolio, gasTankBalance = 0, usdcPrice, + isMaxSelected = false, + maxTokenAmount, } = props; const [isLoading, setIsLoading] = useState(false); @@ -615,12 +619,17 @@ export default function PreviewBuy(props: PreviewBuyProps) { try { // For Relay Buy with EXACT_INPUT, we pass the USD amount directly // The quote will tell us how many tokens we'll receive + // When MAX is selected, use maxTokenAmount to pass the balance directly const newOffer = await getBestOffer({ fromAmount: usdAmount, toTokenAddress: buyToken.address, toChainId: buyToken.chainId, fromChainId, usdcPrice, + maxTokenAmount: + isMaxSelected && maxTokenAmount + ? maxTokenAmount.toString() + : undefined, }); onBuyOfferUpdate(newOffer); @@ -703,6 +712,8 @@ export default function PreviewBuy(props: PreviewBuyProps) { setExpressIntentResponse, clearError, isRelayInitialized, + isMaxSelected, + maxTokenAmount, onBuyOfferUpdate, getBestOffer, fromChainId, diff --git a/src/apps/pulse/components/Buy/tests/Buy.test.tsx b/src/apps/pulse/components/Buy/tests/Buy.test.tsx index f5a19cc7..d305bf57 100644 --- a/src/apps/pulse/components/Buy/tests/Buy.test.tsx +++ b/src/apps/pulse/components/Buy/tests/Buy.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import renderer from 'react-test-renderer'; import { vi } from 'vitest'; +import React from 'react'; // hooks import useTransactionKit from '../../../../../hooks/useTransactionKit'; @@ -268,6 +269,7 @@ const mockProps = { maxStableCoinBalance: { chainId: 1, balance: 10050, + tokenAmount: 10050, }, customBuyAmounts: ['10', '20', '50', '100', 'MAX'], setPreviewBuy: vi.fn(), @@ -278,6 +280,10 @@ const mockProps = { setBuyToken: vi.fn(), chains: MobulaChainNames.All, setChains: vi.fn(), + isMaxSelected: false, + maxTokenAmount: undefined, + setIsMaxSelected: vi.fn(), + setMaxTokenAmount: vi.fn(), }; const defaultMocks = () => { @@ -322,10 +328,40 @@ const defaultMocks = () => { mockGetDispensableAssets.mockReturnValue([[], [], []]); }; -const renderWithProviders = (props = {}) => { +// Wrapper component for tests that need to manage MAX state +const BuyWithState = (props: any) => { + const [isMaxSelected, setIsMaxSelected] = React.useState(false); + const [maxTokenAmount, setMaxTokenAmount] = React.useState< + number | undefined + >(); + + return ( + + ); +}; + +const renderWithProviders = ( + additionalProps: Record = {}, + useStateWrapper = false +) => { + if (useStateWrapper) { + return render( + + + + ); + } + return render( - + ); }; @@ -395,12 +431,13 @@ describe('', () => { }); it('MAX button', () => { - renderWithProviders(); + renderWithProviders({}, true); // Use state wrapper const maxButton = screen.getByText('MAX'); fireEvent.click(maxButton); - expect(screen.getByDisplayValue('10050.00')).toBeInTheDocument(); + // After clicking MAX, the balance should be displayed as text instead of input + expect(screen.getByText('10050.00')).toBeInTheDocument(); }); it('token selector click', () => { @@ -672,6 +709,7 @@ describe('', () => { maxStableCoinBalance: { chainId: 1, balance: 1, // Less than $2 + tokenAmount: 1, }, }); @@ -963,13 +1001,13 @@ describe('', () => { }); it('handles MAX button with Relay Buy', () => { - renderWithProviders(); + renderWithProviders({}, true); // Use state wrapper const maxButton = screen.getByText('MAX'); fireEvent.click(maxButton); - // Should set to max stable coin balance - expect(screen.getByDisplayValue('10050.00')).toBeInTheDocument(); + // Should set to max stable coin balance - displayed as text, not input + expect(screen.getByText('10050.00')).toBeInTheDocument(); }); it('shows minimum amount warning with Relay Buy', async () => { diff --git a/src/apps/pulse/hooks/useRelayBuy.ts b/src/apps/pulse/hooks/useRelayBuy.ts index ed5d6c99..cc0aeb2b 100644 --- a/src/apps/pulse/hooks/useRelayBuy.ts +++ b/src/apps/pulse/hooks/useRelayBuy.ts @@ -42,6 +42,7 @@ interface BuyParams { fromChainId: number; slippage?: number; usdcPrice?: number; // USDC price in USD (e.g., 0.9998), defaults to 1.0 if not provided + maxTokenAmount?: string; // Optional: Use this amount directly instead of converting from USD (e.g., for MAX selections) } export default function useRelayBuy() { @@ -131,6 +132,7 @@ export default function useRelayBuy() { fromChainId, slippage = 0.03, usdcPrice = 1.0, + maxTokenAmount, }: BuyParams): Promise => { if (!isInitialized) { setError('Unable to get quote. Please try again.'); @@ -162,26 +164,42 @@ export default function useRelayBuy() { /** * Step 2: Convert USD amount to USDC amount using actual USDC price - * fromAmount is in USD, we need to convert to USDC amount + * If maxTokenAmount is provided, use it directly (for MAX selections) + * Otherwise, fromAmount is in USD, we need to convert to USDC amount * Then convert to USDC's smallest unit (6 decimals) * Example: $10 USD / $0.9998 USDC price = 10.002 USDC = 10002000 in wei */ let fromAmountInWei: bigint; try { - const usdAmount = parseFloat(fromAmount); - if (Number.isNaN(usdAmount) || usdAmount <= 0) { - throw new Error('Invalid amount'); - } + if (maxTokenAmount) { + // Use maxTokenAmount directly (e.g., for MAX selections) + // Validate that maxTokenAmount is a valid number string + const numeric = Number(maxTokenAmount); + if (Number.isNaN(numeric) || numeric <= 0) { + throw new Error( + 'Invalid maxTokenAmount: must be a positive number' + ); + } - // Convert USD to USDC amount using actual USDC price - // If USDC price is $0.9998, then $10 USD = 10 / 0.9998 = 10.002 USDC - const usdcAmount = usdAmount / usdcPrice; + // Convert to wei using USDC decimals (e.g., 6 on Ethereum, 18 on BSC) + fromAmountInWei = parseUnits(maxTokenAmount, usdcDecimals); + } else { + // Convert USD to USDC amount using actual USDC price + const usdAmount = parseFloat(fromAmount); + if (Number.isNaN(usdAmount) || usdAmount <= 0) { + throw new Error('Invalid amount'); + } - // Convert to wei using USDC decimals (e.g., 6 on Ethereum, 18 on BSC) - fromAmountInWei = parseUnits( - usdcAmount.toFixed(usdcDecimals), - usdcDecimals - ); + // Convert USD to USDC amount using actual USDC price + // If USDC price is $0.9998, then $10 USD = 10 / 0.9998 = 10.002 USDC + const usdcAmount = usdAmount / usdcPrice; + + // Convert to wei using USDC decimals (e.g., 6 on Ethereum, 18 on BSC) + fromAmountInWei = parseUnits( + usdcAmount.toFixed(usdcDecimals), + usdcDecimals + ); + } } catch (parseError) { console.error('Failed to parse fromAmount:', parseError); setError('Invalid amount. Please try again.'); diff --git a/src/apps/pulse/utils/utils.tsx b/src/apps/pulse/utils/utils.tsx index af9c1ef5..d8ef3c4b 100644 --- a/src/apps/pulse/utils/utils.tsx +++ b/src/apps/pulse/utils/utils.tsx @@ -217,17 +217,20 @@ export const canCloseTransaction = ( // Helper function to calculate stable currency balance export const getStableCurrencyBalanceOnEachChain = ( walletPortfolioData: WalletPortfolioMobulaResponse -): { [chainId: number]: { balance: number; price?: number } } => { +): { + [chainId: number]: { balance: number; price?: number; tokenAmount?: number }; +} => { // get the list of chainIds from STABLE_CURRENCIES const chainIds = Array.from( new Set(STABLE_CURRENCIES.map((currency) => currency.chainId)) ); // create a map to hold the balance for each chainId - const balanceMap: { [chainId: number]: { balance: number; price?: number } } = - {}; + const balanceMap: { + [chainId: number]: { balance: number; price?: number; tokenAmount: number }; + } = {}; chainIds.forEach((chainId) => { - balanceMap[chainId] = { balance: 0, price: undefined }; + balanceMap[chainId] = { balance: 0, price: undefined, tokenAmount: 0 }; }); // calculate the balance for each chainId walletPortfolioData?.result.data.assets @@ -255,6 +258,7 @@ export const getStableCurrencyBalanceOnEachChain = ( balanceMap[chainId] = { balance: price * balance, price: asset.price ? asset.price : undefined, + tokenAmount: balance, }; }); });