diff --git a/app/component-library/components/HeaderBase/README.md b/app/component-library/components/HeaderBase/README.md
index 2fb07786f47..700785754a9 100644
--- a/app/component-library/components/HeaderBase/README.md
+++ b/app/component-library/components/HeaderBase/README.md
@@ -19,6 +19,7 @@ Content to wrap to display.
### `variant`
Optional variant to control alignment and text size.
+
- `Compact`: center-aligned with HeadingSm text (default)
- `Display`: left-aligned with HeadingLg text
@@ -86,7 +87,7 @@ Optional style for the header container.
```javascript
// HeaderBase with String title and ButtonIcon props
-
@@ -94,7 +95,7 @@ Optional style for the header container.
;
// HeaderBase with multiple end icons (first item appears rightmost)
-;
// HeaderBase with custom accessories (legacy pattern)
-}
endAccessory={}
>
@@ -114,9 +115,10 @@ Optional style for the header container.
;
// HeaderBase with custom title content
-
+
{CUSTOM_TITLE_NODE}
;
diff --git a/app/components/UI/AddressCopy/AddressCopy.test.tsx b/app/components/UI/AddressCopy/AddressCopy.test.tsx
index fc73ee35598..244ef7a74c4 100644
--- a/app/components/UI/AddressCopy/AddressCopy.test.tsx
+++ b/app/components/UI/AddressCopy/AddressCopy.test.tsx
@@ -5,6 +5,7 @@ import AddressCopy from './AddressCopy';
import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletView.selectors';
import renderWithProvider from '../../../util/test/renderWithProvider';
import { createMockInternalAccount } from '../../../util/test/accountsControllerTestUtils';
+import { ToastContext } from '../../../component-library/components/Toast';
// Mock navigation before importing renderWithProvider
jest.mock('@react-navigation/native', () => ({
@@ -14,8 +15,18 @@ jest.mock('@react-navigation/native', () => ({
}),
}));
+const mockShowToast = jest.fn();
+const mockCloseToast = jest.fn();
+const mockToastRef = {
+ current: { showToast: mockShowToast, closeToast: mockCloseToast },
+};
+
const renderWithAddressCopy = (account: InternalAccount) =>
- renderWithProvider();
+ renderWithProvider(
+
+
+ ,
+ );
describe('AddressCopy', () => {
beforeEach(() => {
@@ -26,6 +37,7 @@ describe('AddressCopy', () => {
const component = renderWithAddressCopy(
createMockInternalAccount('0xaddress', 'Account 1'),
);
+
expect(component).toBeDefined();
expect(
component.getByTestId(WalletViewSelectorsIDs.ACCOUNT_COPY_BUTTON),
diff --git a/app/components/UI/AddressCopy/AddressCopy.tsx b/app/components/UI/AddressCopy/AddressCopy.tsx
index d8ae9ac1fa1..446f22119e2 100644
--- a/app/components/UI/AddressCopy/AddressCopy.tsx
+++ b/app/components/UI/AddressCopy/AddressCopy.tsx
@@ -1,5 +1,5 @@
// Third parties dependencies
-import React, { useCallback } from 'react';
+import React, { useCallback, useContext } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { View } from 'react-native';
import { useNavigation } from '@react-navigation/native';
@@ -12,8 +12,12 @@ import {
IconName,
} from '@metamask/design-system-react-native';
import ClipboardManager from '../../../core/ClipboardManager';
-import { showAlert } from '../../../actions/alert';
import { protectWalletModalVisible } from '../../../actions/user';
+import {
+ ToastContext,
+ ToastVariants,
+} from '../../../component-library/components/Toast';
+import { IconName as ComponentLibraryIconName } from '../../../component-library/components/Icons/Icon';
import { strings } from '../../../../locales/i18n';
import { MetaMetricsEvents } from '../../../core/Analytics';
@@ -26,6 +30,7 @@ import { createAddressListNavigationDetails } from '../../Views/MultichainAccoun
// Internal dependencies
import styleSheet from './AddressCopy.styles';
import { useMetrics } from '../../../components/hooks/useMetrics';
+import { useTheme } from '../../../util/theme';
import { getFormattedAddressFromInternalAccount } from '../../../core/Multichain/utils';
import type { AddressCopyProps } from './AddressCopy.types';
import {
@@ -38,25 +43,17 @@ import {
const AddressCopy = ({ account, iconColor, hitSlop }: AddressCopyProps) => {
const { styles } = useStyles(styleSheet, {});
const { navigate } = useNavigation();
+ const { colors } = useTheme();
const dispatch = useDispatch();
const { trackEvent, createEventBuilder } = useMetrics();
+ const { toastRef } = useContext(ToastContext);
const isMultichainAccountsState2Enabled = useSelector(
selectMultichainAccountsState2Enabled,
);
const selectedAccountGroupId = useSelector(selectSelectedAccountGroupId);
- const handleShowAlert = useCallback(
- (config: {
- isVisible: boolean;
- autodismiss: number;
- content: string;
- data: { msg: string };
- }) => dispatch(showAlert(config)),
- [dispatch],
- );
-
const handleProtectWalletModalVisible = useCallback(
() => dispatch(protectWalletModalVisible()),
[dispatch],
@@ -70,11 +67,15 @@ const AddressCopy = ({ account, iconColor, hitSlop }: AddressCopyProps) => {
await ClipboardManager.setString(
getFormattedAddressFromInternalAccount(account),
);
- handleShowAlert({
- isVisible: true,
- autodismiss: 1500,
- content: 'clipboard-alert',
- data: { msg: strings('account_details.account_copied_to_clipboard') },
+ toastRef?.current?.showToast({
+ variant: ToastVariants.Icon,
+ iconName: ComponentLibraryIconName.CheckBold,
+ iconColor: colors.accent03.dark,
+ backgroundColor: colors.accent03.normal,
+ labelOptions: [
+ { label: strings('account_details.account_copied_to_clipboard') },
+ ],
+ hasNoTimeout: false,
});
setTimeout(() => handleProtectWalletModalVisible(), 2000);
@@ -83,9 +84,11 @@ const AddressCopy = ({ account, iconColor, hitSlop }: AddressCopyProps) => {
);
}, [
account,
+ colors.accent03.dark,
+ colors.accent03.normal,
createEventBuilder,
handleProtectWalletModalVisible,
- handleShowAlert,
+ toastRef,
trackEvent,
]);
diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx
index 7b4e7edf3fc..74902721431 100644
--- a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx
+++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx
@@ -1,13 +1,13 @@
import React from 'react';
import { render } from '@testing-library/react-native';
-// eslint-disable-next-line import/no-namespace
-import * as reactRedux from 'react-redux';
import TokenDetailsList from './';
+import { ToastContext } from '../../../../../component-library/components/Toast';
-jest.mock('react-redux', () => ({
- ...jest.requireActual('react-redux'),
- useDispatch: jest.fn(),
-}));
+const mockShowToast = jest.fn();
+const mockCloseToast = jest.fn();
+const mockToastRef = {
+ current: { showToast: mockShowToast, closeToast: mockCloseToast },
+};
const mockTokenDetails = {
contractAddress: '0x935e73edb9ff52e23bac7f7e043a1ecd06d05477',
@@ -15,17 +15,20 @@ const mockTokenDetails = {
tokenList: 'Metamask, Coinmarketcap',
};
+const renderComponent = () =>
+ render(
+
+
+ ,
+ );
+
describe('TokenDetails', () => {
beforeAll(() => {
jest.resetAllMocks();
});
- it('should render correctly', () => {
- const useDispatchSpy = jest.spyOn(reactRedux, 'useDispatch');
- useDispatchSpy.mockImplementation(() => jest.fn());
- const { toJSON, getByText } = render(
- ,
- );
+ it('renders correctly', () => {
+ const { toJSON, getByText } = renderComponent();
expect(getByText('Token details')).toBeDefined();
expect(getByText('Contract address')).toBeDefined();
diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx
index 7816e44bd75..a8de91f2980 100644
--- a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx
+++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.tsx
@@ -1,9 +1,12 @@
-import React from 'react';
+import React, { useContext } from 'react';
import { TouchableOpacity, View } from 'react-native';
-import { useDispatch } from 'react-redux';
import { useTailwind } from '@metamask/design-system-twrnc-preset';
-import { Icon, IconName, IconSize } from '@metamask/design-system-react-native';
-import { showAlert } from '../../../../../actions/alert';
+import {
+ Icon,
+ IconName as DesignSystemIconName,
+ IconSize,
+} from '@metamask/design-system-react-native';
+import { IconName } from '../../../../../component-library/components/Icons/Icon';
import { strings } from '../../../../../../locales/i18n';
import { useStyles } from '../../../../../component-library/hooks';
import Text, {
@@ -14,6 +17,11 @@ import ClipboardManager from '../../../../../core/ClipboardManager';
import { TokenDetails } from '../TokenDetails';
import TokenDetailsListItem from '../TokenDetailsListItem';
import { formatAddress } from '../../../../../util/address';
+import {
+ ToastContext,
+ ToastVariants,
+} from '../../../../../component-library/components/Toast';
+import { useTheme } from '../../../../../util/theme';
interface TokenDetailsListProps {
tokenDetails: TokenDetails;
@@ -22,25 +30,23 @@ interface TokenDetailsListProps {
const TokenDetailsList: React.FC = ({
tokenDetails,
}) => {
+ const { styles } = useStyles(styleSheet, {});
+ const { toastRef } = useContext(ToastContext);
+ const { colors } = useTheme();
const tw = useTailwind();
- const { styles } = useStyles(styleSheet);
- const dispatch = useDispatch();
-
- const handleShowAlert = (config: {
- isVisible: boolean;
- autodismiss: number;
- content: string;
- data: { msg: string };
- }) => dispatch(showAlert(config));
const copyAccountToClipboard = async () => {
await ClipboardManager.setString(tokenDetails.contractAddress);
- handleShowAlert({
- isVisible: true,
- autodismiss: 1500,
- content: 'clipboard-alert',
- data: { msg: strings('account_details.account_copied_to_clipboard') },
+ toastRef?.current?.showToast({
+ variant: ToastVariants.Icon,
+ iconName: IconName.CheckBold,
+ iconColor: colors.accent03.dark,
+ backgroundColor: colors.accent03.normal,
+ labelOptions: [
+ { label: strings('account_details.account_copied_to_clipboard') },
+ ],
+ hasNoTimeout: false,
});
};
@@ -62,7 +68,7 @@ const TokenDetailsList: React.FC = ({
{formatAddress(tokenDetails.contractAddress, 'short')}
-
+
)}
diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/__snapshots__/TokenDetailsList.test.tsx.snap b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/__snapshots__/TokenDetailsList.test.tsx.snap
index ab5c86e0d51..0e43a6393b2 100644
--- a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/__snapshots__/TokenDetailsList.test.tsx.snap
+++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/__snapshots__/TokenDetailsList.test.tsx.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`TokenDetails should render correctly 1`] = `
+exports[`TokenDetails renders correctly 1`] = `
({
mockUsePerpsMarginAdjustment(opts),
}));
-const mockUsePerpsLiveAccount = jest.fn();
-const mockUsePerpsLivePrices = jest.fn();
+const mockUsePerpsAdjustMarginData = jest.fn();
-jest.mock('../../hooks/stream', () => ({
- usePerpsLiveAccount: () => mockUsePerpsLiveAccount(),
- usePerpsLivePrices: () => mockUsePerpsLivePrices(),
-}));
-
-const mockUsePerpsMarkets = jest.fn();
-
-jest.mock('../../hooks/usePerpsMarkets', () => ({
- usePerpsMarkets: () => mockUsePerpsMarkets(),
+jest.mock('../../hooks/usePerpsAdjustMarginData', () => ({
+ usePerpsAdjustMarginData: (opts: unknown) =>
+ mockUsePerpsAdjustMarginData(opts),
}));
jest.mock('../../hooks/usePerpsMeasurement', () => ({
usePerpsMeasurement: jest.fn(),
}));
-jest.mock('../../utils/marginUtils', () => ({
- calculateMaxRemovableMargin: jest.fn(() => 200),
- calculateNewLiquidationPrice: jest.fn(() => 1800),
-}));
-
jest.mock('../../../../../util/Logger', () => ({
error: jest.fn(),
}));
@@ -167,16 +155,21 @@ describe('PerpsAdjustMarginView', () => {
isAdjusting: false,
});
- mockUsePerpsLiveAccount.mockReturnValue({
- account: { availableBalance: '1000' },
- });
-
- mockUsePerpsLivePrices.mockReturnValue({
- ETH: { price: '2000', markPrice: '2000', percentChange24h: '2.5' },
- });
-
- mockUsePerpsMarkets.mockReturnValue({
- markets: [{ symbol: 'ETH', maxLeverage: '50x' }],
+ // Default mock for add mode - will be overridden in specific tests
+ mockUsePerpsAdjustMarginData.mockReturnValue({
+ position: mockPosition,
+ isLoading: false,
+ currentMargin: 500,
+ positionValue: 5000,
+ maxAmount: 1000, // Available balance for add mode
+ currentLiquidationPrice: 1900,
+ newLiquidationPrice: 1900,
+ currentLiquidationDistance: 5,
+ newLiquidationDistance: 5,
+ availableBalance: 1000,
+ currentPrice: 2000,
+ isAddMode: true,
+ positionLeverage: 10,
});
});
@@ -200,16 +193,17 @@ describe('PerpsAdjustMarginView', () => {
).toBeOnTheScreen();
});
- it('displays perps balance available to add', () => {
+ it('displays current margin and margin available to add', () => {
render();
expect(
- screen.getByText('perps.adjust_margin.perps_balance'),
+ screen.getByText('perps.adjust_margin.margin_in_position'),
).toBeOnTheScreen();
+ expect(screen.getByText('$500.00')).toBeOnTheScreen();
expect(
screen.getByText('perps.adjust_margin.margin_available_to_add'),
).toBeOnTheScreen();
- expect(screen.getAllByText('$1000.00')).toHaveLength(2);
+ expect(screen.getByText('$1000.00')).toBeOnTheScreen();
});
it('displays liquidation price label', () => {
@@ -243,6 +237,23 @@ describe('PerpsAdjustMarginView', () => {
position: mockPosition,
mode: 'remove',
};
+
+ // Override mock for remove mode
+ mockUsePerpsAdjustMarginData.mockReturnValue({
+ position: mockPosition,
+ isLoading: false,
+ currentMargin: 500,
+ positionValue: 5000,
+ maxAmount: 200, // Max removable margin
+ currentLiquidationPrice: 1900,
+ newLiquidationPrice: 1900,
+ currentLiquidationDistance: 5,
+ newLiquidationDistance: 5,
+ availableBalance: 1000,
+ currentPrice: 2000,
+ isAddMode: false,
+ positionLeverage: 10,
+ });
});
it('renders remove margin title', () => {
@@ -253,13 +264,17 @@ describe('PerpsAdjustMarginView', () => {
).toBeOnTheScreen();
});
- it('displays current position margin', () => {
+ it('displays current margin and margin available to remove', () => {
render();
expect(
screen.getByText('perps.adjust_margin.margin_in_position'),
).toBeOnTheScreen();
expect(screen.getByText('$500.00')).toBeOnTheScreen();
+ expect(
+ screen.getByText('perps.adjust_margin.margin_available_to_remove'),
+ ).toBeOnTheScreen();
+ expect(screen.getByText('$200.00')).toBeOnTheScreen();
});
it('displays reduce margin button label', () => {
@@ -291,6 +306,23 @@ describe('PerpsAdjustMarginView', () => {
mode: 'add',
};
+ // Hook returns null position when position not found
+ mockUsePerpsAdjustMarginData.mockReturnValue({
+ position: null,
+ isLoading: false,
+ currentMargin: 0,
+ positionValue: 0,
+ maxAmount: 0,
+ currentLiquidationPrice: 0,
+ newLiquidationPrice: 0,
+ currentLiquidationDistance: 0,
+ newLiquidationDistance: 0,
+ availableBalance: 0,
+ currentPrice: 0,
+ isAddMode: true,
+ positionLeverage: 10,
+ });
+
render();
expect(
@@ -358,6 +390,23 @@ describe('PerpsAdjustMarginView', () => {
position: mockPosition,
mode: 'remove',
};
+
+ // Override mock for remove mode
+ mockUsePerpsAdjustMarginData.mockReturnValue({
+ position: mockPosition,
+ isLoading: false,
+ currentMargin: 500,
+ positionValue: 5000,
+ maxAmount: 200, // Max removable margin
+ currentLiquidationPrice: 1900,
+ newLiquidationPrice: 1900,
+ currentLiquidationDistance: 5,
+ newLiquidationDistance: 5,
+ availableBalance: 1000,
+ currentPrice: 2000,
+ isAddMode: false,
+ positionLeverage: 10,
+ });
});
it('displays margin available to remove', () => {
diff --git a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx
index db74a5db2e4..5e8f1dd7d08 100644
--- a/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx
+++ b/app/components/UI/Perps/Views/PerpsAdjustMarginView/PerpsAdjustMarginView.tsx
@@ -13,7 +13,6 @@ import Button, {
ButtonSize,
} from '../../../../../component-library/components/Buttons/Button';
import { strings } from '../../../../../../locales/i18n';
-import { usePerpsLiveAccount, usePerpsLivePrices } from '../../hooks/stream';
import type { Position } from '../../controllers/types';
import styleSheet from './PerpsAdjustMarginView.styles';
import { useTheme } from '../../../../../util/theme';
@@ -27,14 +26,10 @@ import ButtonIcon, {
} from '../../../../../component-library/components/Buttons/ButtonIcon';
import { usePerpsMarginAdjustment } from '../../hooks/usePerpsMarginAdjustment';
import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement';
-import { usePerpsMarkets } from '../../hooks/usePerpsMarkets';
+import { usePerpsAdjustMarginData } from '../../hooks/usePerpsAdjustMarginData';
import { TraceName } from '../../../../../util/trace';
import Logger from '../../../../../util/Logger';
import { ensureError } from '../../utils/perpsErrorHandler';
-import {
- calculateMaxRemovableMargin,
- calculateNewLiquidationPrice,
-} from '../../utils/marginUtils';
import PerpsAmountDisplay from '../../components/PerpsAmountDisplay';
import PerpsSlider from '../../components/PerpsSlider';
import PerpsBottomSheetTooltip from '../../components/PerpsBottomSheetTooltip';
@@ -45,7 +40,6 @@ import {
PRICE_RANGES_UNIVERSAL,
PRICE_RANGES_MINIMAL_VIEW,
} from '../../utils/formatUtils';
-import { MARGIN_ADJUSTMENT_CONFIG } from '../../constants/perpsConfig';
interface AdjustMarginRouteParams {
position: Position;
@@ -56,10 +50,9 @@ const PerpsAdjustMarginView: React.FC = () => {
const navigation = useNavigation();
const route =
useRoute>();
- const { position, mode } = route.params || {};
+ const { position: routePosition, mode } = route.params || {};
const { styles } = useStyles(styleSheet, {});
const { colors } = useTheme();
- const { account } = usePerpsLiveAccount();
const [marginAmountString, setMarginAmountString] = useState('0');
const [isInputFocused, setIsInputFocused] = useState(false);
@@ -72,26 +65,28 @@ const PerpsAdjustMarginView: React.FC = () => {
[marginAmountString],
);
- const isAddMode = mode === 'add';
-
// Use margin adjustment hook for handling margin operations
const { handleAddMargin, handleRemoveMargin, isAdjusting } =
usePerpsMarginAdjustment({
onSuccess: () => navigation.goBack(),
});
- // Get market info for max leverage (needed for remove mode)
- // Each token has different max leverage limits - must look up from markets
- const { markets } = usePerpsMarkets();
- const marketInfo = useMemo(
- () =>
- position?.coin ? markets.find((m) => m.symbol === position.coin) : null,
- [position?.coin, markets],
- );
- // maxLeverage in PerpsMarketData is a formatted string (e.g., '40x'), parse to number
- const maxLeverage = marketInfo?.maxLeverage
- ? parseInt(marketInfo.maxLeverage, 10)
- : MARGIN_ADJUSTMENT_CONFIG.FALLBACK_MAX_LEVERAGE;
+ // Get all margin data from dedicated hook (uses live subscriptions)
+ const {
+ position,
+ isLoading,
+ currentMargin,
+ maxAmount,
+ currentLiquidationPrice,
+ newLiquidationPrice,
+ currentLiquidationDistance,
+ newLiquidationDistance,
+ isAddMode,
+ } = usePerpsAdjustMarginData({
+ coin: routePosition?.coin || '',
+ mode: mode || 'add',
+ inputAmount: marginAmount,
+ });
// Add performance measurement for this view
usePerpsMeasurement({
@@ -100,137 +95,16 @@ const PerpsAdjustMarginView: React.FC = () => {
debugContext: { mode },
});
- // Get live prices for liquidation distance calculation
- const livePrices = usePerpsLivePrices({
- symbols: position?.coin ? [position.coin] : [],
- throttleMs: 1000,
- });
- const currentPrice = useMemo(
- () => parseFloat(livePrices?.[position?.coin]?.price || '0'),
- [livePrices, position?.coin],
- );
-
- // Current position data
- const currentMargin = useMemo(
- () => parseFloat(position?.marginUsed || '0'),
- [position],
- );
-
- const currentLiquidationPrice = useMemo(
- () => parseFloat(position?.liquidationPrice || '0'),
- [position],
- );
-
- const positionSize = useMemo(
- () => Math.abs(parseFloat(position?.size || '0')),
- [position],
- );
-
- const entryPrice = useMemo(
- () => parseFloat(position?.entryPrice || '0'),
- [position],
- );
-
- const isLong = useMemo(
- () => parseFloat(position?.size || '0') > 0,
- [position],
- );
-
- // Available balance for add mode
- const availableBalance = useMemo(
- () => parseFloat(account?.availableBalance || '0'),
- [account],
- );
-
- // Calculate maximum amount based on mode
- const maxAmount = useMemo(() => {
- if (isAddMode) {
- return Math.max(0, availableBalance);
- }
- return calculateMaxRemovableMargin({
- currentMargin,
- positionSize,
- entryPrice,
- currentPrice,
- maxLeverage,
- });
- }, [
- isAddMode,
- availableBalance,
- currentMargin,
- positionSize,
- entryPrice,
- currentPrice,
- maxLeverage,
- ]);
-
- // Calculate new values after adjustment
- const newMargin = useMemo(() => {
- if (isAddMode) {
- return currentMargin + marginAmount;
- }
- return Math.max(0, currentMargin - marginAmount);
- }, [isAddMode, currentMargin, marginAmount]);
-
- // Calculate new liquidation price
- const newLiquidationPrice = useMemo(() => {
- if (newMargin === 0 || positionSize === 0) return currentLiquidationPrice;
-
- // For add mode, use simplified calculation
- if (isAddMode) {
- const marginPerUnit = newMargin / positionSize;
- if (isLong) {
- return Math.max(0, entryPrice - marginPerUnit);
- }
- return entryPrice + marginPerUnit;
- }
-
- // For remove mode, use utility function
- return calculateNewLiquidationPrice({
- newMargin,
- positionSize,
- entryPrice,
- isLong,
- currentLiquidationPrice,
- });
- }, [
- isAddMode,
- newMargin,
- positionSize,
- entryPrice,
- isLong,
- currentLiquidationPrice,
- ]);
-
- // Calculate liquidation distance percentage
- const calculateLiquidationDistance = useCallback(
- (liquidationPrice: number) => {
- if (currentPrice === 0 || !currentPrice || liquidationPrice === 0) {
- return 0;
- }
- return (Math.abs(currentPrice - liquidationPrice) / currentPrice) * 100;
- },
- [currentPrice],
- );
-
- const currentLiquidationDistance = useMemo(
- () => calculateLiquidationDistance(currentLiquidationPrice),
- [calculateLiquidationDistance, currentLiquidationPrice],
- );
-
- const newLiquidationDistance = useMemo(
- () => calculateLiquidationDistance(newLiquidationPrice),
- [calculateLiquidationDistance, newLiquidationPrice],
- );
-
const handleSliderChange = useCallback((value: number) => {
- // Keep 2 decimal places for precision with small amounts
- setMarginAmountString(value.toFixed(2));
+ // Floor to 2 decimal places to match Hyperliquid behavior
+ const flooredValue = Math.floor(value * 100) / 100;
+ setMarginAmountString(flooredValue.toFixed(2));
}, []);
const handleMaxPress = useCallback(() => {
- // Keep 2 decimal places for precision with small amounts
- setMarginAmountString(maxAmount.toFixed(2));
+ // Floor maxAmount to 2 decimal places
+ const flooredMax = Math.floor(maxAmount * 100) / 100;
+ setMarginAmountString(flooredMax.toFixed(2));
}, [maxAmount]);
// Keypad handlers
@@ -242,8 +116,9 @@ const PerpsAdjustMarginView: React.FC = () => {
({ value }: { value: string }) => {
const numValue = parseFloat(value) || 0;
// Clamp to maxAmount for remove mode to prevent invalid submissions
- if (!isAddMode && numValue > maxAmount) {
- setMarginAmountString(maxAmount.toFixed(2));
+ const flooredMax = Math.floor(maxAmount * 100) / 100;
+ if (!isAddMode && numValue > flooredMax) {
+ setMarginAmountString(flooredMax.toFixed(2));
} else {
setMarginAmountString(value || '0');
}
@@ -257,9 +132,10 @@ const PerpsAdjustMarginView: React.FC = () => {
const handlePercentagePress = useCallback(
(percentage: number) => {
- // Keep 2 decimal places for precision with small amounts
+ // Floor the percentage result
const amount = maxAmount * percentage;
- setMarginAmountString(amount.toFixed(2));
+ const flooredAmount = Math.floor(amount * 100) / 100;
+ setMarginAmountString(flooredAmount.toFixed(2));
},
[maxAmount],
);
@@ -280,7 +156,8 @@ const PerpsAdjustMarginView: React.FC = () => {
if (marginAmount <= 0 || !position) return;
// Prevent submission if amount exceeds max removable (extra safety for remove mode)
- if (!isAddMode && marginAmount > maxAmount) {
+ const flooredMax = Math.floor(maxAmount * 100) / 100;
+ if (!isAddMode && marginAmount > flooredMax) {
return;
}
@@ -306,7 +183,8 @@ const PerpsAdjustMarginView: React.FC = () => {
handleRemoveMargin,
]);
- if (!position || !mode) {
+ // Show error if no position found (either from route or live data)
+ if ((!routePosition && !position) || !mode) {
return (
@@ -326,6 +204,9 @@ const PerpsAdjustMarginView: React.FC = () => {
? strings('perps.adjust_margin.add_margin')
: strings('perps.adjust_margin.reduce_margin');
+ // Floor maxAmount for display and comparison
+ const flooredMaxAmount = Math.floor(maxAmount * 100) / 100;
+
return (
@@ -349,7 +230,7 @@ const PerpsAdjustMarginView: React.FC = () => {
onPress={handleAmountPress}
isActive={isInputFocused}
hasError={false}
- isLoading={false}
+ isLoading={isLoading}
/>
@@ -360,7 +241,7 @@ const PerpsAdjustMarginView: React.FC = () => {
value={marginAmount}
onValueChange={handleSliderChange}
minimumValue={0}
- maximumValue={maxAmount}
+ maximumValue={flooredMaxAmount}
step={0.01}
showPercentageLabels
disabled={false}
@@ -370,15 +251,13 @@ const PerpsAdjustMarginView: React.FC = () => {
{/* Info Section - Always visible */}
- {/* First row: Perps balance or Margin in position */}
+ {/* First row: Current margin */}
- {isAddMode
- ? strings('perps.adjust_margin.perps_balance')
- : strings('perps.adjust_margin.margin_in_position')}
+ {strings('perps.adjust_margin.margin_in_position')}
- {formatPerpsFiat(isAddMode ? availableBalance : currentMargin, {
+ {formatPerpsFiat(currentMargin, {
ranges: PRICE_RANGES_MINIMAL_VIEW,
})}
@@ -392,13 +271,13 @@ const PerpsAdjustMarginView: React.FC = () => {
: strings('perps.adjust_margin.margin_available_to_remove')}
- {formatPerpsFiat(maxAmount, {
+ {formatPerpsFiat(flooredMaxAmount, {
ranges: PRICE_RANGES_MINIMAL_VIEW,
})}
- {/* Liquidation price with transition */}
+ {/* Third row: Liquidation price with transition */}
@@ -445,7 +324,7 @@ const PerpsAdjustMarginView: React.FC = () => {
)}
- {/* Liquidation distance with transition */}
+ {/* Fourth row: Liquidation distance with transition */}
@@ -500,7 +379,7 @@ const PerpsAdjustMarginView: React.FC = () => {
isDisabled={
marginAmount <= 0 ||
isAdjusting ||
- (!isAddMode && marginAmount > maxAmount)
+ (!isAddMode && marginAmount > flooredMaxAmount)
}
loading={isAdjusting}
/>
diff --git a/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.tsx b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.tsx
index 8227cc4fd8c..30260e0aff7 100644
--- a/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.tsx
+++ b/app/components/UI/Perps/components/PerpsAdjustMarginActionSheet/PerpsAdjustMarginActionSheet.tsx
@@ -56,15 +56,12 @@ const PerpsAdjustMarginActionSheet: React.FC<
description: strings('perps.adjust_margin.add_margin_description'),
iconName: IconName.Add,
},
- // TODO: Re-enable remove margin when we can accurately calculate the max removable amount
- // HyperLiquid's formula requires data we don't have access to via API
- // See: https://hyperliquid.gitbook.io/hyperliquid-docs/trading/margin-and-pnl
- // {
- // action: 'reduce_margin',
- // label: strings('perps.adjust_margin.reduce_margin'),
- // description: strings('perps.adjust_margin.reduce_margin_description'),
- // iconName: IconName.Minus,
- // },
+ {
+ action: 'reduce_margin',
+ label: strings('perps.adjust_margin.reduce_margin'),
+ description: strings('perps.adjust_margin.reduce_margin_description'),
+ iconName: IconName.Minus,
+ },
],
[],
);
diff --git a/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.test.tsx b/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.test.tsx
index 53b2f4306b2..e929192f244 100644
--- a/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.test.tsx
+++ b/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.test.tsx
@@ -124,6 +124,13 @@ jest.mock('expo-haptics', () => ({
},
}));
+// Mock usePerpsLivePrices hook (which uses usePerpsStream internally)
+const mockUsePerpsLivePrices = jest.fn();
+jest.mock('../../hooks', () => ({
+ usePerpsLivePrices: (options: { symbols: string[] }) =>
+ mockUsePerpsLivePrices(options),
+}));
+
// usePerpsScreenTracking removed - migrated to usePerpsMeasurement
// Mock BottomSheet components from component library
@@ -311,6 +318,10 @@ describe('PerpsLeverageBottomSheet', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseTheme.mockReturnValue(mockTheme);
+ // Default mock for usePerpsLivePrices - returns price of 3000
+ mockUsePerpsLivePrices.mockReturnValue({
+ 'BTC-USD': { price: '3000' },
+ });
});
describe('Component Rendering', () => {
@@ -365,14 +376,13 @@ describe('PerpsLeverageBottomSheet', () => {
});
it('handles zero prices gracefully', () => {
- // Arrange
- const propsWithZeroPrices = {
- ...defaultProps,
- currentPrice: 0,
- };
+ // Arrange - Mock usePerpsLivePrices to return zero price
+ mockUsePerpsLivePrices.mockReturnValue({
+ 'BTC-USD': { price: '0' },
+ });
// Act
- render();
+ render();
// Assert - Should not crash and show 0.0%
expect(screen.getByText(/0\.0%/)).toBeOnTheScreen();
@@ -528,14 +538,11 @@ describe('PerpsLeverageBottomSheet', () => {
describe('Price Information Display', () => {
it('displays unavailable message when currentPrice is missing', () => {
- // Arrange
- const propsWithoutPrice = {
- ...defaultProps,
- currentPrice: 0,
- };
+ // Arrange - Mock usePerpsLivePrices to return no price
+ mockUsePerpsLivePrices.mockReturnValue({});
// Act
- render();
+ render();
// Assert
expect(
diff --git a/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx b/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx
index 43f8891a037..d2d716e06af 100644
--- a/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx
+++ b/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx
@@ -63,6 +63,7 @@ import {
PRICE_RANGES_UNIVERSAL,
} from '../../utils/formatUtils';
import { createStyles } from './PerpsLeverageBottomSheet.styles';
+import { usePerpsLivePrices } from '../../hooks';
interface PerpsLeverageBottomSheetProps {
isVisible: boolean;
@@ -331,7 +332,6 @@ const PerpsLeverageBottomSheet: React.FC = ({
leverage: initialLeverage,
minLeverage,
maxLeverage,
- currentPrice,
direction,
asset = '',
limitPrice,
@@ -346,6 +346,13 @@ const PerpsLeverageBottomSheet: React.FC = ({
const [inputMethod, setInputMethod] = useState<'slider' | 'preset'>('slider');
const [shouldShowSkeleton, setShouldShowSkeleton] = useState(false);
+ const currentLivePrice = usePerpsLivePrices({
+ symbols: [asset],
+ throttleMs: 1000,
+ });
+
+ const currentPrice = parseFloat(currentLivePrice[asset]?.price);
+
// Dynamically calculate liquidation price based on tempLeverage
// Use limit price for limit orders, market price for market orders
const entryPrice = useMemo(
diff --git a/app/components/UI/Perps/components/PerpsOHLCVBar/PerpsOHLCVBar.tsx b/app/components/UI/Perps/components/PerpsOHLCVBar/PerpsOHLCVBar.tsx
index 8feea89b22d..344bc7e2d4a 100644
--- a/app/components/UI/Perps/components/PerpsOHLCVBar/PerpsOHLCVBar.tsx
+++ b/app/components/UI/Perps/components/PerpsOHLCVBar/PerpsOHLCVBar.tsx
@@ -88,28 +88,58 @@ const PerpsOHLCVBar: React.FC = ({
{/* Values Row */}
-
+
{formattedValues.open}
-
+
{formattedValues.close}
-
+
{formattedValues.high}
-
+
{formattedValues.low}
{formattedValues.volume && (
-
+
{formattedValues.volume}
diff --git a/app/components/UI/Perps/hooks/index.ts b/app/components/UI/Perps/hooks/index.ts
index b32b0e01762..65089d2a7d0 100644
--- a/app/components/UI/Perps/hooks/index.ts
+++ b/app/components/UI/Perps/hooks/index.ts
@@ -43,6 +43,9 @@ export { useWithdrawValidation } from './useWithdrawValidation';
// Payment tokens hook
export { usePerpsPaymentTokens } from './usePerpsPaymentTokens';
+// Margin adjustment hook
+export { usePerpsAdjustMarginData } from './usePerpsAdjustMarginData';
+
// UI utility hooks
export { useBalanceComparison } from './useBalanceComparison';
export { useColorPulseAnimation } from './useColorPulseAnimation';
diff --git a/app/components/UI/Perps/hooks/usePerpsAdjustMarginData.test.ts b/app/components/UI/Perps/hooks/usePerpsAdjustMarginData.test.ts
new file mode 100644
index 00000000000..00065065716
--- /dev/null
+++ b/app/components/UI/Perps/hooks/usePerpsAdjustMarginData.test.ts
@@ -0,0 +1,391 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { usePerpsAdjustMarginData } from './usePerpsAdjustMarginData';
+import {
+ usePerpsLivePositions,
+ usePerpsLiveAccount,
+ usePerpsLivePrices,
+} from './stream';
+import { usePerpsMarkets } from './usePerpsMarkets';
+
+// Mock the dependencies
+jest.mock('./stream', () => ({
+ usePerpsLivePositions: jest.fn(),
+ usePerpsLiveAccount: jest.fn(),
+ usePerpsLivePrices: jest.fn(),
+}));
+
+jest.mock('./usePerpsMarkets', () => ({
+ usePerpsMarkets: jest.fn(),
+}));
+
+const mockUsePerpsLivePositions = usePerpsLivePositions as jest.MockedFunction<
+ typeof usePerpsLivePositions
+>;
+const mockUsePerpsLiveAccount = usePerpsLiveAccount as jest.MockedFunction<
+ typeof usePerpsLiveAccount
+>;
+const mockUsePerpsLivePrices = usePerpsLivePrices as jest.MockedFunction<
+ typeof usePerpsLivePrices
+>;
+const mockUsePerpsMarkets = usePerpsMarkets as jest.MockedFunction<
+ typeof usePerpsMarkets
+>;
+
+describe('usePerpsAdjustMarginData', () => {
+ const mockPosition = {
+ coin: 'BTC',
+ size: '0.5',
+ entryPrice: '100000',
+ liquidationPrice: '80000',
+ marginUsed: '5000',
+ positionValue: '50000',
+ unrealizedPnl: '500',
+ returnOnEquity: '10',
+ leverage: { value: 10, type: 'isolated' as const },
+ maxLeverage: 50,
+ cumulativeFunding: {
+ allTime: '0',
+ sinceOpen: '0',
+ sinceChange: '0',
+ },
+ takeProfitCount: 0,
+ stopLossCount: 0,
+ };
+
+ const mockAccount = {
+ availableBalance: '10000',
+ totalBalance: '15000',
+ marginUsed: '5000',
+ unrealizedPnl: '500',
+ returnOnEquity: '10',
+ };
+
+ const mockMarkets = [
+ {
+ symbol: 'BTC',
+ name: 'Bitcoin',
+ maxLeverage: '50x',
+ price: '$100,000',
+ change24h: '+$2,500',
+ change24hPercent: '2.5%',
+ volume: '$1B',
+ openInterest: '$500M',
+ volumeNumber: 1000000000,
+ },
+ ];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ mockUsePerpsLivePositions.mockReturnValue({
+ positions: [mockPosition],
+ isInitialLoading: false,
+ });
+
+ mockUsePerpsLiveAccount.mockReturnValue({
+ account: mockAccount,
+ isInitialLoading: false,
+ });
+
+ mockUsePerpsLivePrices.mockReturnValue({
+ BTC: { price: '100000', coin: 'BTC', timestamp: Date.now() },
+ });
+
+ mockUsePerpsMarkets.mockReturnValue({
+ markets: mockMarkets,
+ isLoading: false,
+ error: null,
+ refresh: jest.fn(),
+ isRefreshing: false,
+ });
+ });
+
+ describe('position lookup', () => {
+ it('returns the live position for the given coin', () => {
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'BTC',
+ mode: 'remove',
+ inputAmount: 0,
+ }),
+ );
+
+ expect(result.current.position).toEqual(mockPosition);
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('returns null when position is not found', () => {
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'ETH',
+ mode: 'remove',
+ inputAmount: 0,
+ }),
+ );
+
+ expect(result.current.position).toBeNull();
+ });
+
+ it('returns isLoading true when positions are loading', () => {
+ mockUsePerpsLivePositions.mockReturnValue({
+ positions: [],
+ isInitialLoading: true,
+ });
+
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'BTC',
+ mode: 'remove',
+ inputAmount: 0,
+ }),
+ );
+
+ expect(result.current.isLoading).toBe(true);
+ });
+ });
+
+ describe('margin values', () => {
+ it('returns current margin from live position', () => {
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'BTC',
+ mode: 'remove',
+ inputAmount: 0,
+ }),
+ );
+
+ expect(result.current.currentMargin).toBe(5000);
+ });
+
+ it('returns position value from live position', () => {
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'BTC',
+ mode: 'remove',
+ inputAmount: 0,
+ }),
+ );
+
+ expect(result.current.positionValue).toBe(50000);
+ });
+
+ it('returns available balance from live account', () => {
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'BTC',
+ mode: 'add',
+ inputAmount: 0,
+ }),
+ );
+
+ expect(result.current.availableBalance).toBe(10000);
+ });
+ });
+
+ describe('max amount calculation', () => {
+ it('returns available balance as max for add mode', () => {
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'BTC',
+ mode: 'add',
+ inputAmount: 0,
+ }),
+ );
+
+ expect(result.current.maxAmount).toBe(10000);
+ expect(result.current.isAddMode).toBe(true);
+ });
+
+ it('calculates max removable margin for remove mode', () => {
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'BTC',
+ mode: 'remove',
+ inputAmount: 0,
+ }),
+ );
+
+ // positionValue = 50000, leverage = 10
+ // initialMarginRequired = 50000 / 10 = 5000
+ // tenPercentMargin = 50000 * 0.1 = 5000
+ // transferMarginRequired = max(5000, 5000) = 5000
+ // maxRemovable = 5000 - 5000 = 0
+ expect(result.current.maxAmount).toBe(0);
+ expect(result.current.isAddMode).toBe(false);
+ });
+
+ it('calculates positive max removable when margin exceeds requirement', () => {
+ mockUsePerpsLivePositions.mockReturnValue({
+ positions: [
+ {
+ ...mockPosition,
+ marginUsed: '8000', // Extra margin
+ },
+ ],
+ isInitialLoading: false,
+ });
+
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'BTC',
+ mode: 'remove',
+ inputAmount: 0,
+ }),
+ );
+
+ // marginUsed = 8000
+ // transferMarginRequired = 5000 (same as before)
+ // maxRemovable = 8000 - 5000 = 3000
+ expect(result.current.maxAmount).toBe(3000);
+ });
+ });
+
+ describe('liquidation price calculation', () => {
+ it('returns current liquidation price from live position', () => {
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'BTC',
+ mode: 'remove',
+ inputAmount: 0,
+ }),
+ );
+
+ expect(result.current.currentLiquidationPrice).toBe(80000);
+ });
+
+ it('calculates new liquidation price when adding margin', () => {
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'BTC',
+ mode: 'add',
+ inputAmount: 1000,
+ }),
+ );
+
+ // newMargin = 5000 + 1000 = 6000
+ // positionSize = 0.5
+ // marginPerUnit = 6000 / 0.5 = 12000
+ // For long: liquidationPrice = entryPrice - marginPerUnit = 100000 - 12000 = 88000
+ expect(result.current.newLiquidationPrice).toBe(88000);
+ });
+
+ it('calculates new liquidation price when removing margin', () => {
+ mockUsePerpsLivePositions.mockReturnValue({
+ positions: [
+ {
+ ...mockPosition,
+ marginUsed: '8000',
+ },
+ ],
+ isInitialLoading: false,
+ });
+
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'BTC',
+ mode: 'remove',
+ inputAmount: 1000,
+ }),
+ );
+
+ // newMargin = 8000 - 1000 = 7000
+ // marginPerUnit = 7000 / 0.5 = 14000
+ // For long: liquidationPrice = 100000 - 14000 = 86000
+ expect(result.current.newLiquidationPrice).toBe(86000);
+ });
+ });
+
+ describe('liquidation distance calculation', () => {
+ it('calculates current liquidation distance as percentage', () => {
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'BTC',
+ mode: 'remove',
+ inputAmount: 0,
+ }),
+ );
+
+ // currentPrice = 100000, liquidationPrice = 80000
+ // distance = |100000 - 80000| / 100000 * 100 = 20%
+ expect(result.current.currentLiquidationDistance).toBe(20);
+ });
+
+ it('returns 0 when current price is 0', () => {
+ mockUsePerpsLivePrices.mockReturnValue({
+ BTC: { price: '0', coin: 'BTC', timestamp: Date.now() },
+ });
+
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'BTC',
+ mode: 'remove',
+ inputAmount: 0,
+ }),
+ );
+
+ expect(result.current.currentLiquidationDistance).toBe(0);
+ });
+ });
+
+ describe('mode handling', () => {
+ it('correctly identifies add mode', () => {
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'BTC',
+ mode: 'add',
+ inputAmount: 0,
+ }),
+ );
+
+ expect(result.current.isAddMode).toBe(true);
+ });
+
+ it('correctly identifies remove mode', () => {
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'BTC',
+ mode: 'remove',
+ inputAmount: 0,
+ }),
+ );
+
+ expect(result.current.isAddMode).toBe(false);
+ });
+ });
+
+ describe('leverage handling', () => {
+ it('uses position leverage when available', () => {
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'BTC',
+ mode: 'remove',
+ inputAmount: 0,
+ }),
+ );
+
+ expect(result.current.positionLeverage).toBe(10);
+ });
+
+ it('falls back to market max leverage when position leverage value is 0', () => {
+ mockUsePerpsLivePositions.mockReturnValue({
+ positions: [
+ {
+ ...mockPosition,
+ leverage: { value: 0, type: 'cross' as const },
+ },
+ ],
+ isInitialLoading: false,
+ });
+
+ const { result } = renderHook(() =>
+ usePerpsAdjustMarginData({
+ coin: 'BTC',
+ mode: 'remove',
+ inputAmount: 0,
+ }),
+ );
+
+ expect(result.current.positionLeverage).toBe(50);
+ });
+ });
+});
diff --git a/app/components/UI/Perps/hooks/usePerpsAdjustMarginData.ts b/app/components/UI/Perps/hooks/usePerpsAdjustMarginData.ts
new file mode 100644
index 00000000000..29b471a6874
--- /dev/null
+++ b/app/components/UI/Perps/hooks/usePerpsAdjustMarginData.ts
@@ -0,0 +1,230 @@
+import { useMemo, useCallback } from 'react';
+import {
+ usePerpsLivePositions,
+ usePerpsLiveAccount,
+ usePerpsLivePrices,
+} from './stream';
+import { usePerpsMarkets } from './usePerpsMarkets';
+import {
+ calculateMaxRemovableMargin,
+ calculateNewLiquidationPrice,
+} from '../utils/marginUtils';
+import { MARGIN_ADJUSTMENT_CONFIG } from '../constants/perpsConfig';
+import type { Position } from '../controllers/types';
+
+export interface UsePerpsAdjustMarginDataParams {
+ /** Coin symbol from route params to identify the position */
+ coin: string;
+ /** Mode: 'add' or 'remove' */
+ mode: 'add' | 'remove';
+ /** Current user input amount */
+ inputAmount: number;
+}
+
+export interface UsePerpsAdjustMarginDataReturn {
+ /** Live position data (null if not found) */
+ position: Position | null;
+ /** Whether position data is still loading */
+ isLoading: boolean;
+ /** Current margin in position */
+ currentMargin: number;
+ /** Position notional value */
+ positionValue: number;
+ /** Max amount that can be added/removed */
+ maxAmount: number;
+ /** Current liquidation price */
+ currentLiquidationPrice: number;
+ /** New liquidation price after adjustment */
+ newLiquidationPrice: number;
+ /** Current liquidation distance percentage */
+ currentLiquidationDistance: number;
+ /** New liquidation distance percentage */
+ newLiquidationDistance: number;
+ /** Available balance for add mode */
+ availableBalance: number;
+ /** Current market price */
+ currentPrice: number;
+ /** Whether this is add mode */
+ isAddMode: boolean;
+ /** Position leverage */
+ positionLeverage: number;
+}
+
+/**
+ * Hook for margin adjustment data and calculations
+ *
+ * This hook encapsulates all business logic for the adjust margin view:
+ * - Fetches live position data from WebSocket subscription
+ * - Calculates max removable/addable margin
+ * - Computes liquidation price changes
+ *
+ * @param params - Configuration with coin, mode, and input amount
+ * @returns Computed values ready for display
+ */
+export function usePerpsAdjustMarginData(
+ params: UsePerpsAdjustMarginDataParams,
+): UsePerpsAdjustMarginDataReturn {
+ const { coin, mode, inputAmount } = params;
+ const isAddMode = mode === 'add';
+
+ // Live data subscriptions
+ const { positions, isInitialLoading } = usePerpsLivePositions();
+ const { account } = usePerpsLiveAccount();
+ const livePrices = usePerpsLivePrices({
+ symbols: coin ? [coin] : [],
+ throttleMs: 1000,
+ });
+ const { markets } = usePerpsMarkets();
+
+ // Find live position for this coin
+ const position = useMemo(
+ () => positions?.find((p) => p.coin === coin) || null,
+ [positions, coin],
+ );
+
+ // Get market info for max leverage fallback
+ const marketInfo = useMemo(
+ () => (coin ? markets.find((m) => m.symbol === coin) : null),
+ [coin, markets],
+ );
+ const maxLeverage = marketInfo?.maxLeverage
+ ? parseInt(marketInfo.maxLeverage, 10)
+ : MARGIN_ADJUSTMENT_CONFIG.FALLBACK_MAX_LEVERAGE;
+
+ // Derived values from live position
+ const currentMargin = useMemo(
+ () => parseFloat(position?.marginUsed || '0'),
+ [position],
+ );
+
+ const positionValue = useMemo(
+ () => parseFloat(position?.positionValue || '0'),
+ [position],
+ );
+
+ const currentLiquidationPrice = useMemo(
+ () => parseFloat(position?.liquidationPrice || '0'),
+ [position],
+ );
+
+ const positionSize = useMemo(
+ () => Math.abs(parseFloat(position?.size || '0')),
+ [position],
+ );
+
+ const entryPrice = useMemo(
+ () => parseFloat(position?.entryPrice || '0'),
+ [position],
+ );
+
+ const isLong = useMemo(
+ () => parseFloat(position?.size || '0') > 0,
+ [position],
+ );
+
+ const currentPrice = useMemo(
+ () => parseFloat(livePrices?.[coin]?.price || '0'),
+ [livePrices, coin],
+ );
+
+ const availableBalance = useMemo(
+ () => parseFloat(account?.availableBalance || '0'),
+ [account],
+ );
+
+ const positionLeverage = position?.leverage?.value || maxLeverage;
+
+ // Calculate max removable/addable amount
+ const maxAmount = useMemo(() => {
+ if (isAddMode) {
+ return Math.max(0, availableBalance);
+ }
+ return calculateMaxRemovableMargin({
+ currentMargin,
+ positionSize,
+ entryPrice,
+ currentPrice,
+ positionLeverage,
+ notionalValue: positionValue,
+ });
+ }, [
+ isAddMode,
+ availableBalance,
+ currentMargin,
+ positionSize,
+ entryPrice,
+ currentPrice,
+ positionLeverage,
+ positionValue,
+ ]);
+
+ // Calculate new margin after adjustment
+ const newMargin = useMemo(() => {
+ if (isAddMode) {
+ return currentMargin + inputAmount;
+ }
+ return Math.max(0, currentMargin - inputAmount);
+ }, [isAddMode, currentMargin, inputAmount]);
+
+ // Calculate new liquidation price
+ const newLiquidationPrice = useMemo(() => {
+ if (newMargin === 0 || positionSize === 0) return currentLiquidationPrice;
+
+ if (isAddMode) {
+ const marginPerUnit = newMargin / positionSize;
+ return isLong
+ ? Math.max(0, entryPrice - marginPerUnit)
+ : entryPrice + marginPerUnit;
+ }
+
+ return calculateNewLiquidationPrice({
+ newMargin,
+ positionSize,
+ entryPrice,
+ isLong,
+ currentLiquidationPrice,
+ });
+ }, [
+ isAddMode,
+ newMargin,
+ positionSize,
+ entryPrice,
+ isLong,
+ currentLiquidationPrice,
+ ]);
+
+ // Calculate liquidation distance
+ const calculateDistance = useCallback(
+ (liquidationPrice: number) => {
+ if (currentPrice === 0 || liquidationPrice === 0) return 0;
+ return (Math.abs(currentPrice - liquidationPrice) / currentPrice) * 100;
+ },
+ [currentPrice],
+ );
+
+ const currentLiquidationDistance = useMemo(
+ () => calculateDistance(currentLiquidationPrice),
+ [calculateDistance, currentLiquidationPrice],
+ );
+
+ const newLiquidationDistance = useMemo(
+ () => calculateDistance(newLiquidationPrice),
+ [calculateDistance, newLiquidationPrice],
+ );
+
+ return {
+ position,
+ isLoading: isInitialLoading,
+ currentMargin,
+ positionValue,
+ maxAmount,
+ currentLiquidationPrice,
+ newLiquidationPrice,
+ currentLiquidationDistance,
+ newLiquidationDistance,
+ availableBalance,
+ currentPrice,
+ isAddMode,
+ positionLeverage,
+ };
+}
diff --git a/app/components/UI/Perps/utils/marginUtils.test.ts b/app/components/UI/Perps/utils/marginUtils.test.ts
index 1e0949efe0b..a330dfd5d7e 100644
--- a/app/components/UI/Perps/utils/marginUtils.test.ts
+++ b/app/components/UI/Perps/utils/marginUtils.test.ts
@@ -15,98 +15,114 @@ describe('marginUtils', () => {
describe('calculateMaxRemovableMargin', () => {
it('uses 10% minimum when it exceeds leverage-based minimum (high leverage)', () => {
- // For 50x leverage: initial margin = 2%, but 10% is higher
+ // For 50x position leverage: initial margin = 2%, but 10% is higher
const result = calculateMaxRemovableMargin({
- currentMargin: 1000,
+ currentMargin: 3000,
positionSize: 10,
entryPrice: 2000,
currentPrice: 2000,
- maxLeverage: 50,
+ positionLeverage: 50,
});
// notionalValue = 10 * 2000 = 20000
// initialMarginRequired = 20000 / 50 = 400 (2%)
// tenPercentMargin = 20000 * 0.1 = 2000 (10%)
- // baseMinimumRequired = max(400, 2000) = 2000
- // minimumMarginRequired = 2000 * 3 = 6000 (with 3x safety buffer)
- // maxRemovable = 1000 - 6000 = -5000 -> 0 (capped)
- expect(result).toBe(0);
+ // transferMarginRequired = max(400, 2000) = 2000
+ // maxRemovable = 3000 - 2000 = 1000
+ expect(result).toBe(1000);
});
it('uses leverage-based minimum when it exceeds 10% (low leverage)', () => {
- // For 5x leverage: initial margin = 20%, which is > 10%
+ // For 5x position leverage: initial margin = 20%, which is > 10%
const result = calculateMaxRemovableMargin({
- currentMargin: 15000,
+ currentMargin: 5000,
positionSize: 10,
entryPrice: 2000,
currentPrice: 2000,
- maxLeverage: 5,
+ positionLeverage: 5,
});
// notionalValue = 10 * 2000 = 20000
// initialMarginRequired = 20000 / 5 = 4000 (20%)
// tenPercentMargin = 20000 * 0.1 = 2000 (10%)
- // baseMinimumRequired = max(4000, 2000) = 4000
- // minimumMarginRequired = 4000 * 3 = 12000 (with 3x safety buffer)
- // maxRemovable = 15000 - 12000 = 3000
- expect(result).toBe(3000);
+ // transferMarginRequired = max(4000, 2000) = 4000
+ // maxRemovable = 5000 - 4000 = 1000
+ expect(result).toBe(1000);
});
- it('uses higher of entry and current price for conservative calculation', () => {
+ it('uses current price (mark price) per Hyperliquid docs', () => {
const result = calculateMaxRemovableMargin({
- currentMargin: 20000,
+ currentMargin: 6000,
positionSize: 10,
entryPrice: 2000,
currentPrice: 2500, // Higher than entry
- maxLeverage: 5,
+ positionLeverage: 5,
});
- // Uses currentPrice (2500) since it's higher
+ // Uses currentPrice (2500) - mark price per Hyperliquid docs
// notionalValue = 10 * 2500 = 25000
// initialMarginRequired = 25000 / 5 = 5000 (20%)
// tenPercentMargin = 25000 * 0.1 = 2500 (10%)
- // baseMinimumRequired = max(5000, 2500) = 5000
- // minimumMarginRequired = 5000 * 3 = 15000 (with 3x safety buffer)
- // maxRemovable = 20000 - 15000 = 5000
- expect(result).toBe(5000);
+ // transferMarginRequired = max(5000, 2500) = 5000
+ // maxRemovable = 6000 - 5000 = 1000
+ expect(result).toBe(1000);
});
- it('allows margin removal when current margin exceeds minimum with safety buffer', () => {
- // User has 8000 margin for a position requiring 6000 minimum (with buffer)
+ it('allows margin removal when current margin exceeds transfer margin required', () => {
+ // User has 8000 margin for a position requiring 2000 minimum
const result = calculateMaxRemovableMargin({
currentMargin: 8000,
positionSize: 10,
entryPrice: 2000,
currentPrice: 2000,
- maxLeverage: 50,
+ positionLeverage: 50,
});
// notionalValue = 10 * 2000 = 20000
// initialMarginRequired = 20000 / 50 = 400 (2%)
// tenPercentMargin = 20000 * 0.1 = 2000 (10%)
- // baseMinimumRequired = max(400, 2000) = 2000
- // minimumMarginRequired = 2000 * 3 = 6000 (with 3x safety buffer)
- // maxRemovable = 8000 - 6000 = 2000
- expect(result).toBe(2000);
+ // transferMarginRequired = max(400, 2000) = 2000
+ // maxRemovable = 8000 - 2000 = 6000
+ expect(result).toBe(6000);
+ });
+
+ it('correctly calculates for position at exact initial margin (no removable)', () => {
+ // Position at 10x leverage with exactly the required margin
+ // $1000 notional, $100 margin (10% = initial margin at 10x)
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 100,
+ positionSize: 0.01,
+ entryPrice: 100000,
+ currentPrice: 100000,
+ positionLeverage: 10,
+ });
+
+ // notionalValue = 0.01 * 100000 = 1000
+ // initialMarginRequired = 1000 / 10 = 100 (10%)
+ // tenPercentMargin = 1000 * 0.1 = 100 (10%)
+ // transferMarginRequired = max(100, 100) = 100
+ // maxRemovable = 100 - 100 = 0
+ expect(result).toBe(0);
});
- it('correctly limits small positions (real-world scenario)', () => {
- // Real scenario: ~$10.39 notional, $3.50 margin, 3x leverage
+ it('does not include unrealized PnL in withdrawal calculation', () => {
+ // Per Hyperliquid docs: unrealized PnL helps prevent liquidation
+ // but doesn't increase withdrawal limits
+ // Position at exact initial margin - nothing removable
const result = calculateMaxRemovableMargin({
- currentMargin: 3.5,
- positionSize: 0.1,
- entryPrice: 103.9,
- currentPrice: 103.9,
- maxLeverage: 3,
+ currentMargin: 100,
+ positionSize: 0.01,
+ entryPrice: 100000,
+ currentPrice: 100000,
+ positionLeverage: 10,
});
- // notionalValue = 0.1 * 103.9 = 10.39
- // initialMarginRequired = 10.39 / 3 = 3.46 (33%)
- // tenPercentMargin = 10.39 * 0.1 = 1.04 (10%)
- // baseMinimumRequired = max(3.46, 1.04) = 3.46
- // minimumMarginRequired = 3.46 * 3 = 10.38 (with 3x safety buffer)
- // maxRemovable = 3.5 - 10.38 = -6.88 -> 0 (capped)
- // With only 3.5 margin at 3x leverage, no margin can be removed!
+ // notionalValue = 0.01 * 100000 = 1000
+ // initialMarginRequired = 1000 / 10 = 100
+ // tenPercentMargin = 1000 * 0.1 = 100
+ // transferMarginRequired = max(100, 100) = 100
+ // maxRemovable = 100 - 100 = 0
+ // Even if position has positive unrealized PnL, it's not withdrawable
expect(result).toBe(0);
});
@@ -116,7 +132,7 @@ describe('marginUtils', () => {
positionSize: 10,
entryPrice: 2000,
currentPrice: 2000,
- maxLeverage: 50,
+ positionLeverage: 50,
});
expect(result).toBe(0);
@@ -128,46 +144,86 @@ describe('marginUtils', () => {
positionSize: 0,
entryPrice: 2000,
currentPrice: 2000,
- maxLeverage: 50,
+ positionLeverage: 50,
});
expect(result).toBe(0);
});
- it('returns 0 when max leverage is 0', () => {
+ it('returns 0 when position leverage is 0', () => {
const result = calculateMaxRemovableMargin({
currentMargin: 500,
positionSize: 10,
entryPrice: 2000,
currentPrice: 2000,
- maxLeverage: 0,
+ positionLeverage: 0,
});
expect(result).toBe(0);
});
- it('returns 0 when entry price is 0', () => {
+ it('returns 0 when current price is 0 and no notionalValue provided', () => {
const result = calculateMaxRemovableMargin({
currentMargin: 500,
positionSize: 10,
- entryPrice: 0,
- currentPrice: 2000,
- maxLeverage: 50,
+ entryPrice: 2000,
+ currentPrice: 0,
+ positionLeverage: 50,
});
expect(result).toBe(0);
});
- it('returns 0 when current price is 0', () => {
+ it('uses provided notionalValue when currentPrice is 0', () => {
+ // Simulates when live price hasn't loaded yet but we have position.positionValue
const result = calculateMaxRemovableMargin({
- currentMargin: 500,
+ currentMargin: 3000,
positionSize: 10,
entryPrice: 2000,
- currentPrice: 0,
- maxLeverage: 50,
+ currentPrice: 0, // Live price not loaded
+ positionLeverage: 50,
+ notionalValue: 20000, // From position.positionValue
});
- expect(result).toBe(0);
+ // notionalValue = 20000 (provided)
+ // initialMarginRequired = 20000 / 50 = 400 (2%)
+ // tenPercentMargin = 20000 * 0.1 = 2000 (10%)
+ // transferMarginRequired = max(400, 2000) = 2000
+ // maxRemovable = 3000 - 2000 = 1000
+ expect(result).toBe(1000);
+ });
+
+ it('uses calculated notionalValue when provided value is 0', () => {
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 3000,
+ positionSize: 10,
+ entryPrice: 2000,
+ currentPrice: 2000,
+ positionLeverage: 50,
+ notionalValue: 0, // Invalid, should fall back to calculated
+ });
+
+ // Falls back to: notionalValue = 10 * 2000 = 20000
+ // Same calculation as above
+ expect(result).toBe(1000);
+ });
+
+ it('prefers provided notionalValue over calculated when both available', () => {
+ const result = calculateMaxRemovableMargin({
+ currentMargin: 5000,
+ positionSize: 10,
+ entryPrice: 2000,
+ currentPrice: 2500, // Would give notional of 25000
+ positionLeverage: 5,
+ notionalValue: 20000, // Provided value takes precedence
+ });
+
+ // Uses provided notionalValue = 20000
+ // initialMarginRequired = 20000 / 5 = 4000 (20%)
+ // tenPercentMargin = 20000 * 0.1 = 2000 (10%)
+ // transferMarginRequired = max(4000, 2000) = 4000
+ // maxRemovable = 5000 - 4000 = 1000
+ expect(result).toBe(1000);
});
});
diff --git a/app/components/UI/Perps/utils/marginUtils.ts b/app/components/UI/Perps/utils/marginUtils.ts
index d468b1e0a76..233565006a9 100644
--- a/app/components/UI/Perps/utils/marginUtils.ts
+++ b/app/components/UI/Perps/utils/marginUtils.ts
@@ -23,7 +23,10 @@ export interface CalculateMaxRemovableMarginParams {
positionSize: number;
entryPrice: number;
currentPrice: number;
- maxLeverage: number;
+ /** The actual leverage of the position (not the asset's max leverage) */
+ positionLeverage: number;
+ /** Optional pre-calculated notional value (e.g., from position.positionValue) for immediate display before live prices load */
+ notionalValue?: number;
}
export interface CalculateNewLiquidationPriceParams {
@@ -86,65 +89,80 @@ export function assessMarginRemovalRisk(
*
* HyperLiquid enforces: transfer_margin_required = max(initial_margin_required, 0.1 * total_position_value)
* See: https://hyperliquid.gitbook.io/hyperliquid-docs/trading/margin-and-pnl
+ * See also: docs/perps/hyperliquid/margining.md
*
- * For high leverage assets (e.g., 50x where initial margin = 2%),
- * the 10% requirement is the binding constraint.
+ * Key insight from Hyperliquid support (Xulian, Dec 6, 2025):
+ * "you need to account for initial margin for withdrawal, maintenance is what is needed to not be liquidated"
*
- * IMPORTANT: We apply an additional 50% safety buffer on top of the minimum required
- * because HyperLiquid's actual margin requirements can vary based on market conditions,
- * unrealized PnL, and other factors not captured in this simplified calculation.
+ * The initial margin is calculated using the POSITION'S leverage (not the asset's max leverage).
+ * For example, a position opened at 10x leverage requires 10% initial margin,
+ * not 2% (which would be for 50x max leverage).
*
- * @param params - Current margin, position size, entry price, current price, and max leverage limit
+ * @param params - Current margin, position size, prices, and position leverage
* @returns Maximum removable margin amount in USD
*/
export function calculateMaxRemovableMargin(
params: CalculateMaxRemovableMarginParams,
): number {
- const { currentMargin, positionSize, entryPrice, currentPrice, maxLeverage } =
- params;
+ const {
+ currentMargin,
+ positionSize,
+ currentPrice,
+ positionLeverage,
+ notionalValue: providedNotionalValue,
+ } = params;
// Validate inputs
if (
isNaN(currentMargin) ||
- isNaN(positionSize) ||
- isNaN(entryPrice) ||
- isNaN(currentPrice) ||
- isNaN(maxLeverage) ||
+ isNaN(positionLeverage) ||
currentMargin <= 0 ||
- positionSize <= 0 ||
- entryPrice <= 0 ||
- currentPrice <= 0 ||
- maxLeverage <= 0
+ positionLeverage <= 0
) {
return 0;
}
- // Use the higher price to be conservative (HyperLiquid uses current mark price)
- const price = Math.max(entryPrice, currentPrice);
-
- // Calculate notional value
- const notionalValue = positionSize * price;
+ // Use provided notional value (e.g., from position.positionValue) or calculate from price
+ // This allows immediate display before live prices load
+ let notionalValue = providedNotionalValue;
+ if (
+ notionalValue === undefined ||
+ isNaN(notionalValue) ||
+ notionalValue <= 0
+ ) {
+ // Fall back to calculating from price if not provided or invalid
+ if (
+ isNaN(positionSize) ||
+ isNaN(currentPrice) ||
+ positionSize <= 0 ||
+ currentPrice <= 0
+ ) {
+ return 0;
+ }
+ notionalValue = positionSize * currentPrice;
+ }
- // HyperLiquid's transfer margin requirement:
+ // Hyperliquid's transfer margin requirement formula:
// transfer_margin_required = max(initial_margin_required, 0.1 * total_position_value)
- const initialMarginRequired = notionalValue / maxLeverage;
+ //
+ // IMPORTANT: Use the position's actual leverage, not the asset's max leverage
+ // A position at 10x leverage needs 10% initial margin ($100 for $1000 notional)
+ // NOT the 2% that 50x max leverage would imply
+ const initialMarginRequired = notionalValue / positionLeverage;
const tenPercentMargin =
notionalValue * MARGIN_ADJUSTMENT_CONFIG.MARGIN_REMOVAL_SAFETY_BUFFER;
- // Minimum margin is the MAX of these two constraints
- const baseMinimumRequired = Math.max(initialMarginRequired, tenPercentMargin);
-
- // Apply 3x safety buffer because HyperLiquid's actual requirements are significantly higher
- // than the documented formula due to:
- // - Maintenance margin requirements
- // - Unrealized PnL impact
- // - Market volatility adjustments
- // - Funding rate considerations
- // Testing showed 1.5x was insufficient - $2 removal rejected when calc showed $2.95 available
- const minimumMarginRequired = baseMinimumRequired * 3;
-
- // Maximum removable = current - minimum (must be non-negative)
- return Math.max(0, currentMargin - minimumMarginRequired);
+ // Transfer margin required is the MAX of these two constraints
+ const transferMarginRequired = Math.max(
+ initialMarginRequired,
+ tenPercentMargin,
+ );
+
+ // Note: Unrealized PnL is NOT counted as part of "remaining margin" for withdrawals
+ // Per Hyperliquid docs, unrealized PnL helps prevent liquidation but doesn't
+ // increase your available withdrawal limit for margin transfers
+ // Maximum removable = current margin - required (must be non-negative)
+ return Math.max(0, currentMargin - transferMarginRequired);
}
/**
diff --git a/app/components/Views/AddressQRCode/AddressQRCode.test.tsx b/app/components/Views/AddressQRCode/AddressQRCode.test.tsx
new file mode 100644
index 00000000000..75b10dba5a9
--- /dev/null
+++ b/app/components/Views/AddressQRCode/AddressQRCode.test.tsx
@@ -0,0 +1,169 @@
+import React from 'react';
+import { TouchableOpacity } from 'react-native';
+import { fireEvent, waitFor } from '@testing-library/react-native';
+import { act } from '@testing-library/react-hooks';
+import AddressQRCode from './index';
+import renderWithProvider from '../../../util/test/renderWithProvider';
+import { ToastContext } from '../../../component-library/components/Toast';
+import ClipboardManager from '../../../core/ClipboardManager';
+import { backgroundState } from '../../../util/test/initial-root-state';
+
+jest.mock('../../../core/ClipboardManager', () => ({
+ setString: jest.fn().mockResolvedValue(undefined),
+}));
+
+jest.mock('react-native-qrcode-svg', () => 'QRCode');
+
+const mockDispatch = jest.fn();
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useDispatch: () => mockDispatch,
+}));
+
+const mockShowToast = jest.fn();
+const mockToastRef = {
+ current: { showToast: mockShowToast, closeToast: jest.fn() },
+};
+
+const mockCloseQrModal = jest.fn();
+
+const mockAddress = '0x1234567890abcdef1234567890abcdef12345678';
+
+const mockInitialState = {
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ AccountsController: {
+ internalAccounts: {
+ selectedAccount: 'account-1',
+ accounts: {
+ 'account-1': {
+ id: 'account-1',
+ address: mockAddress,
+ metadata: { name: 'Account 1' },
+ type: 'eip155:eoa' as const,
+ },
+ },
+ },
+ },
+ },
+ },
+ user: {
+ seedphraseBackedUp: true,
+ },
+};
+
+const renderComponent = (state = mockInitialState) =>
+ renderWithProvider(
+
+
+ ,
+ { state },
+ );
+
+describe('AddressQRCode', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('copies selected address to clipboard when address text is pressed', async () => {
+ const { getByText } = renderComponent();
+ const addressText = getByText(/0x 1234/);
+
+ await act(async () => {
+ fireEvent.press(addressText);
+ });
+
+ await waitFor(() => {
+ expect(ClipboardManager.setString).toHaveBeenCalledWith(
+ expect.stringContaining('0x'),
+ );
+ });
+ });
+
+ it('shows toast notification after copying address', async () => {
+ const { getByText } = renderComponent();
+ const addressText = getByText(/0x 1234/);
+
+ await act(async () => {
+ fireEvent.press(addressText);
+ });
+
+ await waitFor(() => {
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variant: 'Icon',
+ hasNoTimeout: false,
+ }),
+ );
+ });
+ });
+
+ it('calls closeQrModal callback when close button is pressed', async () => {
+ const { UNSAFE_getAllByType } = renderComponent();
+ const touchables = UNSAFE_getAllByType(TouchableOpacity);
+ const closeButton = touchables[0];
+
+ await act(async () => {
+ fireEvent.press(closeButton);
+ });
+
+ expect(mockCloseQrModal).toHaveBeenCalledTimes(1);
+ });
+
+ it('dispatches protectWalletModalVisible after close when seedphrase not backed up', async () => {
+ const stateWithUnbackedSeedphrase = {
+ ...mockInitialState,
+ user: {
+ seedphraseBackedUp: false,
+ },
+ };
+ const { UNSAFE_getAllByType } = renderComponent(
+ stateWithUnbackedSeedphrase,
+ );
+ const touchables = UNSAFE_getAllByType(TouchableOpacity);
+ const closeButton = touchables[0];
+
+ await act(async () => {
+ fireEvent.press(closeButton);
+ });
+
+ expect(mockCloseQrModal).toHaveBeenCalled();
+
+ await act(async () => {
+ jest.advanceTimersByTime(1000);
+ });
+
+ expect(mockDispatch).toHaveBeenCalled();
+ });
+
+ it('does not dispatch protectWalletModalVisible when seedphrase is backed up', async () => {
+ const { UNSAFE_getAllByType } = renderComponent();
+ const touchables = UNSAFE_getAllByType(TouchableOpacity);
+ const closeButton = touchables[0];
+
+ await act(async () => {
+ fireEvent.press(closeButton);
+ });
+
+ await act(async () => {
+ jest.advanceTimersByTime(1000);
+ });
+
+ expect(mockDispatch).not.toHaveBeenCalled();
+ });
+
+ it('passes ethereum-prefixed address to QRCode for ETH addresses', () => {
+ const { UNSAFE_getByType } = renderComponent();
+ const qrCode = UNSAFE_getByType(
+ 'QRCode' as unknown as React.ComponentType,
+ );
+
+ expect(qrCode.props.value).toMatch(/^ethereum:0x/);
+ });
+});
diff --git a/app/components/Views/AddressQRCode/index.js b/app/components/Views/AddressQRCode/index.js
index bcb08c1e87a..30a683ee3e7 100644
--- a/app/components/Views/AddressQRCode/index.js
+++ b/app/components/Views/AddressQRCode/index.js
@@ -1,4 +1,4 @@
-import React, { PureComponent } from 'react';
+import React, { useCallback, useContext, useMemo } from 'react';
import PropTypes from 'prop-types';
import {
TouchableOpacity,
@@ -8,18 +8,21 @@ import {
Text,
} from 'react-native';
import { fontStyles } from '../../../styles/common';
-import { connect } from 'react-redux';
+import { useSelector, useDispatch } from 'react-redux';
import QRCode from 'react-native-qrcode-svg';
import { strings } from '../../../../locales/i18n';
import IonicIcon from 'react-native-vector-icons/Ionicons';
import Device from '../../../util/device';
-import { showAlert } from '../../../actions/alert';
-import GlobalAlert from '../../UI/GlobalAlert';
import { protectWalletModalVisible } from '../../../actions/user';
import ClipboardManager from '../../../core/ClipboardManager';
-import { ThemeContext, mockTheme } from '../../../util/theme';
+import { useTheme } from '../../../util/theme';
import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController';
import { isEthAddress } from '../../../util/address';
+import {
+ ToastContext,
+ ToastVariants,
+} from '../../../component-library/components/Toast';
+import { IconName } from '../../../component-library/components/Icons/Icon';
const WIDTH = Dimensions.get('window').width - 88;
@@ -78,117 +81,95 @@ const createStyles = (theme) =>
});
/**
- * PureComponent that renders a public address view
+ * Functional component that renders a public address view
*/
-class AddressQRCode extends PureComponent {
- static propTypes = {
- /**
- * Selected address as string
- */
- selectedAddress: PropTypes.string,
- /**
- /* Triggers global alert
- */
- showAlert: PropTypes.func,
- /**
- /* Callback to close the modal
- */
- closeQrModal: PropTypes.func,
- /**
- * Prompts protect wallet modal
- */
- protectWalletModalVisible: PropTypes.func,
- /**
- * redux flag that indicates if the user
- * completed the seed phrase backup flow
- */
- seedphraseBackedUp: PropTypes.bool,
- };
+const AddressQRCode = ({ closeQrModal }) => {
+ const theme = useTheme();
+ const { colors } = theme;
+ const styles = useMemo(() => createStyles(theme), [theme]);
+ const dispatch = useDispatch();
+ const { toastRef } = useContext(ToastContext);
+
+ const selectedAddress = useSelector(
+ selectSelectedInternalAccountFormattedAddress,
+ );
+ const seedphraseBackedUp = useSelector(
+ (state) => state.user.seedphraseBackedUp,
+ );
+
+ const handleProtectWalletModalVisible = useCallback(
+ () => dispatch(protectWalletModalVisible()),
+ [dispatch],
+ );
/**
* Closes QR code modal
*/
- closeQrModal = () => {
- this.props.closeQrModal();
- !this.props.seedphraseBackedUp &&
- setTimeout(() => this.props.protectWalletModalVisible(), 1000);
- };
+ const handleCloseQrModal = useCallback(() => {
+ closeQrModal();
+ if (!seedphraseBackedUp) {
+ setTimeout(() => handleProtectWalletModalVisible(), 1000);
+ }
+ }, [closeQrModal, seedphraseBackedUp, handleProtectWalletModalVisible]);
- copyAccountToClipboard = async () => {
- const { selectedAddress } = this.props;
+ const copyAccountToClipboard = useCallback(async () => {
await ClipboardManager.setString(selectedAddress);
- this.props.showAlert({
- isVisible: true,
- autodismiss: 1500,
- content: 'clipboard-alert',
- data: { msg: strings('account_details.account_copied_to_clipboard') },
+ toastRef?.current?.showToast({
+ variant: ToastVariants.Icon,
+ iconName: IconName.CheckBold,
+ iconColor: colors.accent03.dark,
+ backgroundColor: colors.accent03.normal,
+ labelOptions: [
+ { label: strings('account_details.account_copied_to_clipboard') },
+ ],
+ hasNoTimeout: false,
});
- };
+ }, [colors.accent03.dark, colors.accent03.normal, selectedAddress, toastRef]);
- processAddress = () => {
- const { selectedAddress } = this.props;
+ const processAddress = useCallback(() => {
const processedAddress = `${selectedAddress.slice(0, 2)} ${selectedAddress
.slice(2)
.match(/.{1,4}/g)
.join(' ')}`;
return processedAddress;
- };
+ }, [selectedAddress]);
- render() {
- const theme = this.context || mockTheme;
- const colors = theme.colors;
- const styles = createStyles(theme);
+ const qrValue = isEthAddress(selectedAddress)
+ ? `ethereum:${selectedAddress}`
+ : selectedAddress;
- const qrValue = isEthAddress(this.props.selectedAddress)
- ? `ethereum:${this.props.selectedAddress}`
- : this.props.selectedAddress;
-
- return (
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- {strings('receive_request.public_address_qr_code')}
-
-
- {this.processAddress()}
-
-
+
+
+ {strings('receive_request.public_address_qr_code')}
+
+
+ {processAddress()}
+
+
- );
- }
-}
-
-const mapStateToProps = (state) => ({
- selectedAddress: selectSelectedInternalAccountFormattedAddress(state),
- seedphraseBackedUp: state.user.seedphraseBackedUp,
-});
+
+ );
+};
-const mapDispatchToProps = (dispatch) => ({
- showAlert: (config) => dispatch(showAlert(config)),
- protectWalletModalVisible: () => dispatch(protectWalletModalVisible()),
-});
-
-AddressQRCode.contextType = ThemeContext;
+AddressQRCode.propTypes = {
+ /**
+ * Callback to close the modal
+ */
+ closeQrModal: PropTypes.func,
+};
-export default connect(mapStateToProps, mapDispatchToProps)(AddressQRCode);
+export default AddressQRCode;
diff --git a/app/components/Views/AssetDetails/index.tsx b/app/components/Views/AssetDetails/index.tsx
index 672fe4dad43..2ab52c96750 100644
--- a/app/components/Views/AssetDetails/index.tsx
+++ b/app/components/Views/AssetDetails/index.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useMemo } from 'react';
+import React, { useCallback, useContext, useEffect, useMemo } from 'react';
import {
View,
StyleSheet,
@@ -11,9 +11,13 @@ import { useNavigation } from '@react-navigation/native';
import { getNetworkNavbarOptions } from '../../UI/Navbar';
import { fontStyles } from '../../../styles/common';
import ClipboardManager from '../../../core/ClipboardManager';
-import { showAlert } from '../../../actions/alert';
import { strings } from '../../../../locales/i18n';
-import { useDispatch, useSelector } from 'react-redux';
+import { useSelector } from 'react-redux';
+import {
+ ToastContext,
+ ToastVariants,
+} from '../../../component-library/components/Toast';
+import { IconName as ComponentLibraryIconName } from '../../../component-library/components/Icons/Icon';
import EthereumAddress from '../../UI/EthereumAddress';
import Icon from 'react-native-vector-icons/Feather';
import TokenImage from '../../UI/TokenImage';
@@ -120,7 +124,7 @@ const AssetDetails = (props: InnerProps) => {
const { trackEvent, createEventBuilder } = useMetrics();
const styles = createStyles(colors);
const navigation = useNavigation();
- const dispatch = useDispatch();
+ const { toastRef } = useContext(ToastContext);
const providerConfig = useSelector(selectProviderConfig);
const selectedAccountAddressEvm = useSelector(selectLastSelectedEvmAccount);
@@ -187,14 +191,16 @@ const AssetDetails = (props: InnerProps) => {
const copyAddressToClipboard = async () => {
await ClipboardManager.setString(address);
- dispatch(
- showAlert({
- isVisible: true,
- autodismiss: 1500,
- content: 'clipboard-alert',
- data: { msg: strings('detected_tokens.address_copied_to_clipboard') },
- }),
- );
+ toastRef?.current?.showToast({
+ variant: ToastVariants.Icon,
+ iconName: ComponentLibraryIconName.CheckBold,
+ iconColor: colors.accent03.dark,
+ backgroundColor: colors.accent03.normal,
+ labelOptions: [
+ { label: strings('detected_tokens.address_copied_to_clipboard') },
+ ],
+ hasNoTimeout: false,
+ });
};
const triggerHideToken = () => {
diff --git a/app/components/Views/Notifications/Details/hooks/useCopyClipboard.test.ts b/app/components/Views/Notifications/Details/hooks/useCopyClipboard.test.ts
new file mode 100644
index 00000000000..c3ca9b39e66
--- /dev/null
+++ b/app/components/Views/Notifications/Details/hooks/useCopyClipboard.test.ts
@@ -0,0 +1,107 @@
+import { renderHook, act } from '@testing-library/react-hooks';
+import React from 'react';
+import useCopyClipboard from './useCopyClipboard';
+import ClipboardManager from '../../../../../core/ClipboardManager';
+import { ToastContext } from '../../../../../component-library/components/Toast';
+import { IconName } from '../../../../../component-library/components/Icons/Icon';
+
+jest.mock('../../../../../core/ClipboardManager', () => ({
+ setString: jest.fn().mockResolvedValue(undefined),
+}));
+
+const mockDispatch = jest.fn();
+jest.mock('react-redux', () => ({
+ useDispatch: () => mockDispatch,
+}));
+
+const mockShowToast = jest.fn();
+const mockToastRef = {
+ current: { showToast: mockShowToast, closeToast: jest.fn() },
+};
+
+const wrapper = ({ children }: { children: React.ReactNode }) =>
+ React.createElement(
+ ToastContext.Provider,
+ { value: { toastRef: mockToastRef } },
+ children,
+ );
+
+describe('useCopyClipboard', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('copies provided value to system clipboard', async () => {
+ const { result } = renderHook(() => useCopyClipboard(), { wrapper });
+ const testAddress = '0x1234567890abcdef';
+
+ await act(async () => {
+ await result.current(testAddress);
+ });
+
+ expect(ClipboardManager.setString).toHaveBeenCalledWith(testAddress);
+ });
+
+ it('shows toast with Icon variant after copying', async () => {
+ const { result } = renderHook(() => useCopyClipboard(), { wrapper });
+
+ await act(async () => {
+ await result.current('0x1234567890abcdef');
+ });
+
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variant: 'Icon',
+ iconName: IconName.CheckBold,
+ hasNoTimeout: false,
+ }),
+ );
+ });
+
+ it('uses custom alert text in toast when provided', async () => {
+ const { result } = renderHook(() => useCopyClipboard(), { wrapper });
+ const customMessage = 'Transaction ID copied';
+
+ await act(async () => {
+ await result.current('0x1234', customMessage);
+ });
+
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ labelOptions: [{ label: customMessage }],
+ }),
+ );
+ });
+
+ it('skips clipboard and toast when value is empty string', async () => {
+ const { result } = renderHook(() => useCopyClipboard(), { wrapper });
+
+ await act(async () => {
+ await result.current('');
+ });
+
+ expect(ClipboardManager.setString).not.toHaveBeenCalled();
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+
+ it('dispatches protectWalletModalVisible after 2 second delay', async () => {
+ const { result } = renderHook(() => useCopyClipboard(), { wrapper });
+
+ await act(async () => {
+ await result.current('0x1234');
+ });
+
+ expect(mockDispatch).not.toHaveBeenCalled();
+
+ await act(async () => {
+ jest.advanceTimersByTime(2000);
+ });
+
+ expect(mockDispatch).toHaveBeenCalled();
+ });
+});
diff --git a/app/components/Views/Notifications/Details/hooks/useCopyClipboard.ts b/app/components/Views/Notifications/Details/hooks/useCopyClipboard.ts
index fe99ed1c76b..49643057b3e 100644
--- a/app/components/Views/Notifications/Details/hooks/useCopyClipboard.ts
+++ b/app/components/Views/Notifications/Details/hooks/useCopyClipboard.ts
@@ -1,8 +1,14 @@
+import { useCallback, useContext } from 'react';
import { useDispatch } from 'react-redux';
import { strings } from '../../../../../../locales/i18n';
-import { showAlert } from '../../../../../actions/alert';
import { protectWalletModalVisible } from '../../../../../actions/user';
import ClipboardManager from '../../../../../core/ClipboardManager';
+import {
+ ToastContext,
+ ToastVariants,
+} from '../../../../../component-library/components/Toast';
+import { IconName } from '../../../../../component-library/components/Icons/Icon';
+import { useTheme } from '../../../../../util/theme';
export const CopyClipboardAlertMessage = {
default: (): string => strings('notifications.copied_to_clipboard'),
@@ -13,30 +19,37 @@ export const CopyClipboardAlertMessage = {
function useCopyClipboard() {
const dispatch = useDispatch();
+ const { toastRef } = useContext(ToastContext);
+ const { colors } = useTheme();
- const handleShowAlert = (config: {
- isVisible: boolean;
- autodismiss: number;
- content: string;
- data: { msg: string };
- }) => dispatch(showAlert(config));
+ const handleProtectWalletModalVisible = useCallback(
+ () => dispatch(protectWalletModalVisible()),
+ [dispatch],
+ );
- const handleProtectWalletModalVisible = () =>
- dispatch(protectWalletModalVisible());
-
- const copyToClipboard = async (value: string, alertText?: string) => {
- if (!value) return;
- await ClipboardManager.setString(value);
- handleShowAlert({
- isVisible: true,
- autodismiss: 1500,
- content: 'clipboard-alert',
- data: {
- msg: alertText ?? CopyClipboardAlertMessage.default(),
- },
- });
- setTimeout(() => handleProtectWalletModalVisible(), 2000);
- };
+ const copyToClipboard = useCallback(
+ async (value: string, alertText?: string) => {
+ if (!value) return;
+ await ClipboardManager.setString(value);
+ toastRef?.current?.showToast({
+ variant: ToastVariants.Icon,
+ iconName: IconName.CheckBold,
+ iconColor: colors.accent03.dark,
+ backgroundColor: colors.accent03.normal,
+ labelOptions: [
+ { label: alertText ?? CopyClipboardAlertMessage.default() },
+ ],
+ hasNoTimeout: false,
+ });
+ setTimeout(() => handleProtectWalletModalVisible(), 2000);
+ },
+ [
+ colors.accent03.dark,
+ colors.accent03.normal,
+ toastRef,
+ handleProtectWalletModalVisible,
+ ],
+ );
return copyToClipboard;
}
diff --git a/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.tsx b/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.tsx
index 3061ff6ac8f..b0c6035369d 100644
--- a/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.tsx
+++ b/app/components/Views/RevealPrivateCredential/RevealPrivateCredential.tsx
@@ -9,7 +9,6 @@ import {
View,
} from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
-import { wordlist } from '@metamask/scure-bip39/dist/wordlists/english';
import { InternalAccount } from '@metamask/keyring-internal-api';
import QRCode from 'react-native-qrcode-svg';
import { RouteProp, ParamListBase } from '@react-navigation/native';
@@ -18,7 +17,6 @@ import ScrollableTabView from '@tommasini/react-native-scrollable-tab-view';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const CustomTabView = ScrollView as any;
import { store } from '../../../store';
-import StorageWrapper from '../../../store/storage-wrapper';
import ActionView from '../../UI/ActionView';
import ButtonReveal from '../../UI/ButtonReveal';
import Button, {
@@ -41,12 +39,10 @@ import {
} from '../../../constants/urls';
import ClipboardManager from '../../../core/ClipboardManager';
import { useTheme } from '../../../util/theme';
-import Engine from '../../../core/Engine';
-import { BIOMETRY_CHOICE } from '../../../constants/storage';
import { MetaMetricsEvents } from '../../../core/Analytics';
-import { uint8ArrayToMnemonic } from '../../../util/mnemonic';
import { passwordRequirementsMet } from '../../../util/password';
-import { Authentication } from '../../../core/';
+import useAuthentication from '../../../core/Authentication/hooks/useAuthentication';
+import { ReauthenticateErrorType } from '../../../core/Authentication/types';
import { isTest } from '../../../util/test/utils';
import Device from '../../../util/device';
@@ -122,6 +118,7 @@ const RevealPrivateCredential = ({
const [clipboardEnabled, setClipboardEnabled] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const passwordInputRef = useRef(null);
+ const { reauthenticate, revealSRP, revealPrivateKey } = useAuthentication();
const keyringId = route?.params?.keyringId;
@@ -129,10 +126,6 @@ const RevealPrivateCredential = ({
selectSelectedInternalAccountFormattedAddress,
);
- // TODO: Replace "any" with type
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const passwordSet = useSelector((state: any) => state.user.passwordSet);
-
const dispatch = useDispatch();
const theme = useTheme();
@@ -164,36 +157,38 @@ const RevealPrivateCredential = ({
);
};
- const tryUnlockWithPassword = useCallback(
- async (pswd: string, privCredentialName?: string) => {
- // TODO: Replace "any" with type
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const { KeyringController } = Engine.context as any;
+ const revealCredential = useCallback(
+ async (pswd?: string) => {
+ setIsModalVisible(false);
+ const privCredentialName = credentialName || route?.params.credentialName;
const isPrivateKeyReveal = privCredentialName === PRIVATE_KEY;
-
- // This will trigger after the user hold-pressed the button, we want to trace the actual
- // keyring operation of extracting the credential
const traceName = isPrivateKeyReveal
? TraceName.RevealPrivateKey
: TraceName.RevealSrp;
- trace({
- name: traceName,
- op: TraceOperation.RevealPrivateCredential,
- tags: getTraceTags(store.getState()),
- });
+
+ let passwordToUse = pswd;
try {
+ if (!passwordToUse) {
+ const { password: verifiedPassword } = await reauthenticate();
+ passwordToUse = verifiedPassword;
+ }
+
+ // This will trigger after the user has been authenticated, we want to trace the actual
+ // keyring operation of extracting the credential.
+ trace({
+ name: traceName,
+ op: TraceOperation.RevealPrivateCredential,
+ tags: getTraceTags(store.getState()),
+ });
+
let privateCredential;
if (!isPrivateKeyReveal) {
- const uint8ArraySeed = await KeyringController.exportSeedPhrase(
- pswd,
- keyringId,
- );
- privateCredential = uint8ArrayToMnemonic(uint8ArraySeed, wordlist);
+ privateCredential = await revealSRP(passwordToUse, keyringId);
} else {
- privateCredential = await KeyringController.exportAccount(
- pswd,
- selectedAddress,
+ privateCredential = await revealPrivateKey(
+ passwordToUse,
+ selectedAddress as string,
);
}
@@ -209,6 +204,10 @@ const RevealPrivateCredential = ({
// TODO: Replace "any" with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
+ // we should not show the error message if the error is because biometric is not enabled
+ if (e.message.includes(ReauthenticateErrorType.BIOMETRIC_NOT_ENABLED)) {
+ return;
+ }
let msg = strings('reveal_credential.warning_incorrect_password');
if (selectedAddress && isHardwareAccount(selectedAddress)) {
msg = strings('reveal_credential.hardware_error');
@@ -223,9 +222,21 @@ const RevealPrivateCredential = ({
setWarningIncorrectPassword(msg);
}
},
- [selectedAddress, keyringId],
+ [
+ selectedAddress,
+ keyringId,
+ credentialName,
+ route?.params.credentialName,
+ reauthenticate,
+ revealSRP,
+ revealPrivateKey,
+ ],
);
+ const revealCredentialWithPassword = () => {
+ revealCredential(password);
+ };
+
useEffect(() => {
updateNavBar();
// Track SRP Reveal screen rendered
@@ -235,23 +246,7 @@ const RevealPrivateCredential = ({
);
}
- const unlockWithBiometrics = async () => {
- // Try to use biometrics to unlock
- const { availableBiometryType } = await Authentication.getType();
- if (!passwordSet) {
- tryUnlockWithPassword('');
- } else if (availableBiometryType) {
- const biometryChoice = await StorageWrapper.getItem(BIOMETRY_CHOICE);
- if (biometryChoice !== '' && biometryChoice === availableBiometryType) {
- const credentials = await Authentication.getPassword();
- if (credentials) {
- tryUnlockWithPassword(credentials.password);
- }
- }
- }
- };
-
- unlockWithBiometrics();
+ revealCredential();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -286,11 +281,8 @@ const RevealPrivateCredential = ({
};
const tryUnlock = async () => {
- // TODO: Replace "any" with type
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const { KeyringController } = Engine.context as any;
try {
- await KeyringController.verifyPassword(password);
+ await reauthenticate(password);
} catch {
const msg = strings('reveal_credential.warning_incorrect_password');
setWarningIncorrectPassword(msg);
@@ -359,17 +351,6 @@ const RevealPrivateCredential = ({
);
};
- const revealCredential = useCallback(() => {
- const credential = credentialName || route?.params.credentialName;
- tryUnlockWithPassword(password, credential);
- setIsModalVisible(false);
- }, [
- credentialName,
- password,
- route?.params.credentialName,
- tryUnlockWithPassword,
- ]);
-
const renderTabBar = () => ;
const onTabBarChange = (event: { i: number }) => {
@@ -572,7 +553,7 @@ const RevealPrivateCredential = ({
})}
variant={ButtonVariants.Primary}
size={ButtonSize.Lg}
- onPress={revealCredential}
+ onPress={revealCredentialWithPassword}
style={styles.revealButton}
testID={RevealSeedViewSelectorsIDs.REVEAL_CREDENTIAL_BUTTON_ID}
/>
@@ -583,7 +564,7 @@ const RevealPrivateCredential = ({
? strings('reveal_credential.private_key_text')
: strings('reveal_credential.srp_abbreviation_text'),
})}
- onLongPress={revealCredential}
+ onLongPress={revealCredentialWithPassword}
/>
)}
>
diff --git a/app/core/Authentication/Authentication.test.ts b/app/core/Authentication/Authentication.test.ts
index f30dd7ba78e..99c1c0e683d 100644
--- a/app/core/Authentication/Authentication.test.ts
+++ b/app/core/Authentication/Authentication.test.ts
@@ -53,6 +53,7 @@ import Logger from '../../util/Logger';
import Routes from '../../constants/navigation/Routes';
import { strings } from '../../../locales/i18n';
import { IconName } from '../../component-library/components/Icons/Icon';
+import { ReauthenticateErrorType } from './types';
export type RecursivePartial = {
[P in keyof T]?: RecursivePartial;
@@ -3736,6 +3737,24 @@ describe('Authentication', () => {
expect(result.password).toBe('test-password');
});
+ it('throws PASSWORD_REQUIRED error when no biometric credentials are available', async () => {
+ const verifyPasswordSpy = Engine.context.KeyringController.verifyPassword;
+ const getItemSpy = jest
+ .spyOn(StorageWrapper, 'getItem')
+ .mockResolvedValueOnce(null as never);
+ const getPasswordSpy = jest
+ .spyOn(Authentication, 'getPassword')
+ .mockResolvedValueOnce(null);
+
+ await expect(Authentication.reauthenticate()).rejects.toThrow(
+ ReauthenticateErrorType.BIOMETRIC_NOT_ENABLED,
+ );
+
+ expect(getItemSpy).toHaveBeenCalledWith(BIOMETRY_CHOICE);
+ expect(getPasswordSpy).not.toHaveBeenCalled();
+ expect(verifyPasswordSpy).not.toHaveBeenCalled();
+ });
+
it('uses stored biometric password when no password is provided', async () => {
const verifyPasswordSpy = Engine.context.KeyringController.verifyPassword;
await StorageWrapper.setItem(BIOMETRY_CHOICE, TRUE);
@@ -3765,4 +3784,58 @@ describe('Authentication', () => {
);
});
});
+
+ describe('revealSRP', () => {
+ let Engine: typeof import('../Engine').default;
+
+ beforeEach(() => {
+ Engine = jest.requireMock('../Engine');
+ Engine.context.KeyringController.exportSeedPhrase = jest
+ .fn()
+ .mockResolvedValue(new Uint8Array([1, 2, 3]));
+ jest.spyOn(Authentication, 'reauthenticate').mockResolvedValue({
+ password: 'valid-password',
+ });
+ });
+
+ it('calls reauthenticate and exports SRP with the provided password and keyringId', async () => {
+ const reauthSpy = jest.spyOn(Authentication, 'reauthenticate');
+ const exportSeedPhraseSpy =
+ Engine.context.KeyringController.exportSeedPhrase;
+ const keyringId = 'keyring-id';
+
+ await Authentication.revealSRP('valid-password', keyringId);
+
+ expect(reauthSpy).toHaveBeenCalledWith('valid-password');
+ expect(exportSeedPhraseSpy).toHaveBeenCalledWith(
+ 'valid-password',
+ keyringId,
+ );
+ });
+ });
+
+ describe('revealPrivateKey', () => {
+ let Engine: typeof import('../Engine').default;
+
+ beforeEach(() => {
+ Engine = jest.requireMock('../Engine');
+ Engine.context.KeyringController.exportAccount = jest
+ .fn()
+ .mockResolvedValue('0xprivatekey');
+ jest.spyOn(Authentication, 'reauthenticate').mockResolvedValue({
+ password: 'valid-password',
+ });
+ });
+
+ it('calls reauthenticate and exports private key with the provided password and address', async () => {
+ const reauthSpy = jest.spyOn(Authentication, 'reauthenticate');
+ const exportAccountSpy = Engine.context.KeyringController.exportAccount;
+ const address = '0x123';
+
+ await Authentication.revealPrivateKey('valid-password', address);
+
+ expect(reauthSpy).toHaveBeenCalledWith('valid-password');
+ expect(exportAccountSpy).toHaveBeenCalledWith('valid-password', address);
+ });
+ });
});
diff --git a/app/core/Authentication/Authentication.ts b/app/core/Authentication/Authentication.ts
index 4aed9ec3d3d..fab5a8d8fb4 100644
--- a/app/core/Authentication/Authentication.ts
+++ b/app/core/Authentication/Authentication.ts
@@ -80,6 +80,7 @@ import MetaMetrics from '../Analytics/MetaMetrics';
import { resetProviderToken as depositResetProviderToken } from '../../components/UI/Ramp/Deposit/utils/ProviderTokenVault';
import { strings } from '../../../locales/i18n';
import { IconName } from '../../component-library/components/Icons/Icon';
+import { ReauthenticateErrorType } from './types';
/**
* Holds auth data used to determine auth configuration
@@ -1431,12 +1432,10 @@ class AuthenticationService {
*
* @param password - Optional password to verify. When omitted, the method
* attempts to use the stored biometric/remember-me password instead.
- * @returns The verified password string, or `undefined` if verification fails before
- * a password can be determined.
+ * @returns The verified password string. Throws an error if verification fails
+ * before a password can be determined.
*/
- reauthenticate = async (
- password?: string,
- ): Promise<{ password: string | undefined }> => {
+ reauthenticate = async (password?: string): Promise<{ password: string }> => {
let passwordToVerify = password || '';
const { KeyringController } = Engine.context;
@@ -1449,11 +1448,57 @@ class AuthenticationService {
passwordToVerify = credentials.password;
}
}
+
+ // If there is no biometric choice configured or no stored credentials,
+ // throw a specific error instead of attempting to verify an empty password.
+ if (!passwordToVerify) {
+ const biometricNotEnabledErrorMessage = 'Biometric is not enabled';
+ throw new Error(
+ `${ReauthenticateErrorType.BIOMETRIC_NOT_ENABLED}: ${biometricNotEnabledErrorMessage}`,
+ );
+ }
}
await KeyringController.verifyPassword(passwordToVerify);
return { password: passwordToVerify };
};
+
+ /**
+ * Reveals the secret recovery phrase (SRP) for the specified keyring
+ * after verifying the provided password via `reauthenticate`.
+ *
+ * @param password - The password used to authenticate the user.
+ * @param keyringId - The identifier of the keyring whose SRP will be exported.
+ * @returns The mnemonic SRP associated with the provided keyring.
+ */
+ revealSRP = async (password: string, keyringId?: string): Promise => {
+ const { KeyringController } = Engine.context;
+ await this.reauthenticate(password);
+ const rawSeedPhrase = await KeyringController.exportSeedPhrase(
+ password,
+ keyringId,
+ );
+ const seedPhrase = uint8ArrayToMnemonic(rawSeedPhrase, wordlist);
+ return seedPhrase;
+ };
+
+ /**
+ * Reveals the private key for the given account address after verifying
+ * the provided password via `reauthenticate`.
+ *
+ * @param password - The password used to authenticate the user.
+ * @param address - The account address whose private key will be exported.
+ * @returns The hex-encoded private key for the specified address.
+ */
+ revealPrivateKey = async (
+ password: string,
+ address: string,
+ ): Promise => {
+ const { KeyringController } = Engine.context;
+ await this.reauthenticate(password);
+ const privateKey = await KeyringController.exportAccount(password, address);
+ return privateKey;
+ };
}
export const Authentication = new AuthenticationService();
diff --git a/app/core/Authentication/hooks/useAuthentication.ts b/app/core/Authentication/hooks/useAuthentication.ts
new file mode 100644
index 00000000000..ed8b720ab58
--- /dev/null
+++ b/app/core/Authentication/hooks/useAuthentication.ts
@@ -0,0 +1,10 @@
+import { Authentication } from '../Authentication';
+
+/**
+ * Hook that interfaces with the Authentication service.
+ */
+export default () => ({
+ reauthenticate: Authentication.reauthenticate,
+ revealSRP: Authentication.revealSRP,
+ revealPrivateKey: Authentication.revealPrivateKey,
+});
diff --git a/app/core/Authentication/types.ts b/app/core/Authentication/types.ts
new file mode 100644
index 00000000000..e00b9d4d9c6
--- /dev/null
+++ b/app/core/Authentication/types.ts
@@ -0,0 +1,3 @@
+export enum ReauthenticateErrorType {
+ BIOMETRIC_NOT_ENABLED = 'BIOMETRIC_NOT_ENABLED',
+}
diff --git a/docs/perps/hyperliquid/margining.md b/docs/perps/hyperliquid/margining.md
new file mode 100644
index 00000000000..ccc61c410ff
--- /dev/null
+++ b/docs/perps/hyperliquid/margining.md
@@ -0,0 +1,29 @@
+# Margining
+
+Margin computations follow similar formulas to major centralized derivatives exchanges.
+
+### Margin Mode
+
+When opening a position, a margin mode is selected. _Cross margin_ is the default, which allows for maximal capital efficiency by sharing collateral between all other cross margin positions. _Isolated margin_ is also supported, which allows an asset's collateral to be constrained to that asset. Liquidations in that asset do not affect other isolated positions or cross positions. Similarly, cross liquidations or other isolated liquidations do not affect the original isolated position.
+
+Some assets are _isolated-only_, which functions the same as isolated margin with the additional constraint that margin cannot be removed. Margin is proportionally removed as the position is closed.
+
+### Initial Margin and Leverage
+
+Leverage can be set by a user to any integer between 1 and the max leverage. Max leverage depends on the asset.
+
+The margin required to open a position is `position_size * mark_price / leverage`. The initial margin is used by the position and cannot be withdrawn for cross margin positions. Isolated positions support adding and removing margin after opening the position. Unrealized pnl for cross margin positions will automatically be available as initial margin for new positions, while isolated positions will apply unrealized pnl as additional margin for the open position.\
+\
+The leverage of an existing position can be increased without closing the position. Leverage is only checked upon opening a position. Afterwards, the user is responsible for monitoring the leverage usage to avoid liquidation. Possible actions to take on positions with negative unrealized pnl include partially or fully closing the position, adding margin (if isolated), and depositing USDC (if cross).
+
+### Unrealized PNL and transfer margin requirements
+
+Unrealized pnl can be withdrawn from isolated positions or cross account, but only if the remaining margin is at least 10% of the total notional position value of all open positions. The margin remaining must also meet the initial margin requirement, i.e. `transfer_margin_required = max(initial_margin_required, 0.1 * total_position_value)`
+
+Here, "transferring" includes any action that removes margin from a position, other than trading. Examples include withdrawals, transfer to spot wallet, and isolated margin transfers.
+
+### Maintenance Margin and Liquidations
+
+Cross positions are liquidated when the account value (including unrealized pnl) is less than the _maintenance margin_ times the total open notional position. The maintenance margin is currently set to half of the initial margin at max leverage.
+
+Isolated positions are liquidated by the same maintenance margin logic, but the only inputs to the computation are the isolated margin and the notional value of the isolated position.