Skip to content
Merged
23 changes: 17 additions & 6 deletions app/components/Base/StatusText.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,41 +15,50 @@ const styles = StyleSheet.create({
},
});

export const ConfirmedText = ({ testID, ...props }) => (
<Text testID={testID} bold green style={styles.status} {...props} />
export const ConfirmedText = ({ testID, style: styleProp, ...props }) => (
<Text
testID={testID}
bold
green
style={[styles.status, styleProp]}
{...props}
/>
);
ConfirmedText.propTypes = {
testID: PropTypes.string,
style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
};

export const PendingText = ({ testID, ...props }) => {
export const PendingText = ({ testID, style: styleProp, ...props }) => {
const { colors } = useTheme();
return (
<Text
testID={testID}
bold
style={[styles.status, { color: colors.warning.default }]}
style={[styles.status, { color: colors.warning.default }, styleProp]}
{...props}
/>
);
};
PendingText.propTypes = {
testID: PropTypes.string,
style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
};

export const FailedText = ({ testID, ...props }) => {
export const FailedText = ({ testID, style: styleProp, ...props }) => {
const { colors } = useTheme();
return (
<Text
testID={testID}
bold
style={[styles.status, { color: colors.error.default }]}
style={[styles.status, { color: colors.error.default }, styleProp]}
{...props}
/>
);
};
FailedText.propTypes = {
testID: PropTypes.string,
style: PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
};

function StatusText({ status, context, testID, ...props }) {
Expand All @@ -65,6 +74,8 @@ function StatusText({ status, context, testID, ...props }) {
case 'pending':
case 'Submitted':
case 'submitted':
case 'Unconfirmed':
case 'unconfirmed':
case TransactionStatus.signed:
return (
<PendingText testID={testID} {...props}>
Expand Down
36 changes: 31 additions & 5 deletions app/components/UI/Assets/hooks/useTrendingRequest/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,20 @@ import {
SortTrendingBy,
} from '@metamask/assets-controllers';
import { useStableArray } from '../../../Perps/hooks/useStableArray';
import {
NetworkType,
useNetworksByNamespace,
ProcessedNetwork,
} from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace';
import { useNetworksToUse } from '../../../../hooks/useNetworksToUse/useNetworksToUse';
export const DEBOUNCE_WAIT = 500;

/**
* Hook for handling trending tokens request
* @returns {Object} An object containing the trending tokens results, loading state, error, and a function to trigger fetch
*/
export const useTrendingRequest = (options: {
chainIds: CaipChainId[];
chainIds?: CaipChainId[];
sortBy?: SortTrendingBy;
minLiquidity?: number;
minVolume24hUsd?: number;
Expand All @@ -22,7 +28,7 @@ export const useTrendingRequest = (options: {
maxMarketCap?: number;
}) => {
const {
chainIds,
chainIds: providedChainIds = [],
sortBy,
minLiquidity,
minVolume24hUsd,
Expand All @@ -31,10 +37,30 @@ export const useTrendingRequest = (options: {
maxMarketCap,
} = options;

// Get default networks when chainIds is empty
const { networks } = useNetworksByNamespace({
networkType: NetworkType.Popular,
});

const { networksToUse } = useNetworksToUse({
networks,
networkType: NetworkType.Popular,
});

// Use provided chainIds or default to popular networks
const chainIds = useMemo((): CaipChainId[] => {
if (providedChainIds.length > 0) {
return providedChainIds;
}
return networksToUse.map(
(network: ProcessedNetwork) => network.caipChainId,
);
}, [providedChainIds, networksToUse]);

const [results, setResults] = useState<Awaited<
ReturnType<typeof getTrendingTokens>
> | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);

// Track the current request ID to prevent stale results from overwriting current ones
Expand Down Expand Up @@ -111,7 +137,7 @@ export const useTrendingRequest = (options: {
debouncedFetchTrendingTokens.cancel();

// If chainIds is empty, don't trigger fetch
if (!memoizedOptions.chainIds.length) {
if (!stableChainIds.length) {
return;
}

Expand All @@ -122,7 +148,7 @@ export const useTrendingRequest = (options: {
return () => {
debouncedFetchTrendingTokens.cancel();
};
}, [debouncedFetchTrendingTokens, memoizedOptions.chainIds.length]);
}, [debouncedFetchTrendingTokens, stableChainIds]);

return {
results: results || [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,76 @@ import { renderHookWithProvider } from '../../../../../util/test/renderWithProvi
import { act } from '@testing-library/react-native';
// eslint-disable-next-line import/no-namespace
import * as assetsControllers from '@metamask/assets-controllers';
import {
ProcessedNetwork,
useNetworksByNamespace,
} from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace';
import { useNetworksToUse } from '../../../../hooks/useNetworksToUse/useNetworksToUse';

// Mock the network hooks
jest.mock(
'../../../../hooks/useNetworksByNamespace/useNetworksByNamespace',
() => ({
useNetworksByNamespace: jest.fn(),
NetworkType: {
Popular: 'popular',
Custom: 'custom',
},
}),
);

jest.mock('../../../../hooks/useNetworksToUse/useNetworksToUse', () => ({
useNetworksToUse: jest.fn(),
}));

const mockUseNetworksByNamespace =
useNetworksByNamespace as jest.MockedFunction<typeof useNetworksByNamespace>;
const mockUseNetworksToUse = useNetworksToUse as jest.MockedFunction<
typeof useNetworksToUse
>;

// Default mock networks
const mockDefaultNetworks: ProcessedNetwork[] = [
{
id: '1',
name: 'Ethereum Mainnet',
caipChainId: 'eip155:1' as const,
isSelected: true,
imageSource: { uri: 'ethereum' },
},
{
id: '137',
name: 'Polygon',
caipChainId: 'eip155:137' as const,
isSelected: true,
imageSource: { uri: 'polygon' },
},
];

describe('useTrendingRequest', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
// Set up default mocks for network hooks
mockUseNetworksByNamespace.mockReturnValue({
networks: mockDefaultNetworks,
selectedNetworks: mockDefaultNetworks,
areAllNetworksSelected: true,
areAnyNetworksSelected: true,
networkCount: mockDefaultNetworks.length,
selectedCount: mockDefaultNetworks.length,
});
mockUseNetworksToUse.mockReturnValue({
networksToUse: mockDefaultNetworks,
evmNetworks: mockDefaultNetworks,
solanaNetworks: [],
selectedEvmAccount: null,
selectedSolanaAccount: null,
isMultichainAccountsState2Enabled: false,
areAllNetworksSelectedCombined: true,
areAllEvmNetworksSelected: true,
areAllSolanaNetworksSelected: false,
} as unknown as ReturnType<typeof useNetworksToUse>);
});

it('returns an object with results, isLoading, error, and fetch function', () => {
Expand Down Expand Up @@ -195,12 +260,23 @@ describe('useTrendingRequest', () => {
unmount();
});

it('skips fetch when chain ids are empty', async () => {
it('uses default popular networks when chainIds is empty', async () => {
const spyGetTrendingTokens = jest.spyOn(
assetsControllers,
'getTrendingTokens',
);
spyGetTrendingTokens.mockResolvedValue([]);
const mockResults: assetsControllers.TrendingAsset[] = [
{
assetId: 'eip155:1/erc20:0x123',
symbol: 'TOKEN1',
name: 'Token 1',
decimals: 18,
price: '1',
aggregatedUsdVolume: 1,
marketCap: 1,
},
];
spyGetTrendingTokens.mockResolvedValue(mockResults as never);

const { result, unmount } = renderHookWithProvider(() =>
useTrendingRequest({
Expand All @@ -213,20 +289,82 @@ describe('useTrendingRequest', () => {
await Promise.resolve();
});

expect(spyGetTrendingTokens).not.toHaveBeenCalled();
expect(result.current.results).toEqual([]);
expect(mockUseNetworksByNamespace).toHaveBeenCalledWith({
networkType: 'popular',
});
expect(mockUseNetworksToUse).toHaveBeenCalledWith({
networks: mockDefaultNetworks,
networkType: 'popular',
});
expect(spyGetTrendingTokens).toHaveBeenCalledWith(
expect.objectContaining({
chainIds: ['eip155:1', 'eip155:137'],
}),
);
expect(result.current.results).toEqual(mockResults);
expect(result.current.isLoading).toBe(false);

spyGetTrendingTokens.mockRestore();
unmount();
});

it('uses default popular networks when chainIds is not provided', async () => {
const spyGetTrendingTokens = jest.spyOn(
assetsControllers,
'getTrendingTokens',
);
const mockResults: assetsControllers.TrendingAsset[] = [];
spyGetTrendingTokens.mockResolvedValue(mockResults as never);

renderHookWithProvider(() => useTrendingRequest({}));

await act(async () => {
await result.current.fetch();
jest.advanceTimersByTime(DEBOUNCE_WAIT);
await Promise.resolve();
});

expect(spyGetTrendingTokens).not.toHaveBeenCalled();
expect(mockUseNetworksByNamespace).toHaveBeenCalledWith({
networkType: 'popular',
});
expect(spyGetTrendingTokens).toHaveBeenCalledWith(
expect.objectContaining({
chainIds: ['eip155:1', 'eip155:137'],
}),
);

spyGetTrendingTokens.mockRestore();
});

it('uses provided chainIds when available instead of default networks', async () => {
const spyGetTrendingTokens = jest.spyOn(
assetsControllers,
'getTrendingTokens',
);
const mockResults: assetsControllers.TrendingAsset[] = [];
spyGetTrendingTokens.mockResolvedValue(mockResults as never);

const customChainIds: `${string}:${string}`[] = [
'eip155:56',
'eip155:42161',
];
renderHookWithProvider(() =>
useTrendingRequest({
chainIds: customChainIds,
}),
);

await act(async () => {
jest.advanceTimersByTime(DEBOUNCE_WAIT);
await Promise.resolve();
});

expect(spyGetTrendingTokens).toHaveBeenCalledWith(
expect.objectContaining({
chainIds: customChainIds,
}),
);

spyGetTrendingTokens.mockRestore();
unmount();
});

it('coalesces multiple rapid calls into a single fetch', async () => {
Expand Down
30 changes: 15 additions & 15 deletions app/components/UI/Perps/Debug/HIP3DebugView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import styleSheet from './HIP3DebugView.styles';
import Engine from '../../../../core/Engine';
import type { HyperLiquidProvider } from '../controllers/providers/HyperLiquidProvider';
import type { MarketInfo } from '../controllers/types';
import { findOptimalAmount } from '../utils/orderCalculations';

interface TestResult {
status: 'idle' | 'loading' | 'success' | 'error';
Expand Down Expand Up @@ -367,36 +366,37 @@ const HIP3DebugView: React.FC = () => {
const szDecimals = marketInfo.szDecimals;

// Calculate position size for $11 USD notional value
// Use findOptimalAmount to handle rounding correctly and ensure we meet $10 minimum
// USD as source of truth - provider will recalculate size with fresh price
const targetUsdAmount = 11;
const optimalAmount = findOptimalAmount({
targetAmount: targetUsdAmount.toString(),
maxAllowedAmount: 1000, // Reasonable max for test orders
minAllowedAmount: 10, // HyperLiquid minimum order size
price: currentPrice,
szDecimals,
});

// Calculate actual position size from optimal amount
const positionSize = parseFloat(optimalAmount) / currentPrice;
// Calculate position size from USD amount
const positionSize = targetUsdAmount / currentPrice;
const multiplier = Math.pow(10, szDecimals);
const roundedPositionSize =
Math.round(positionSize * multiplier) / multiplier;

DevLogger.log('Order calculation:', {
market: selectedMarket,
currentPrice: currentPrice.toFixed(2),
szDecimals,
targetAmount: targetUsdAmount,
optimalAmount,
calculatedPositionSize: positionSize.toFixed(szDecimals),
expectedNotional: (positionSize * currentPrice).toFixed(2),
calculatedPositionSize: roundedPositionSize.toFixed(szDecimals),
expectedNotional: (roundedPositionSize * currentPrice).toFixed(2),
});

// Place order with calculated size
// USD-as-source-of-truth: provide currentPrice and usdAmount for validation
const result = await provider.placeOrder({
coin: selectedMarket,
isBuy: true,
size: positionSize.toFixed(szDecimals),
size: roundedPositionSize.toFixed(szDecimals),
orderType: 'market',
leverage: 5,
// Required by USD-as-source-of-truth validation
currentPrice,
usdAmount: targetUsdAmount.toString(),
priceAtCalculation: currentPrice,
maxSlippageBps: 100, // 1% slippage tolerance
});

if (result.success) {
Expand Down
Loading
Loading