Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ const PredictBuyWithAnyToken = () => {
depositFee,
rewardsFeeAmount,
totalPayForPredictBalance,
hasBlockingPayAlerts,
blockingPayAlertMessage,
} = usePredictBuyInfo({
currentValue,
preview,
Expand All @@ -153,6 +155,7 @@ const PredictBuyWithAnyToken = () => {
isConfirming,
totalPayForPredictBalance,
isInputFocused,
hasBlockingPayAlerts,
});

const { errorMessage, isOrderNotFilled, resetOrderNotFilled } =
Expand All @@ -166,6 +169,7 @@ const PredictBuyWithAnyToken = () => {
isConfirming,
isPayFeesLoading,
isInputFocused,
blockingPayAlertMessage,
});

const { handleConfirm, placeOrder } = usePredictBuyActions({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ jest.mock('../../../../../../../util/address', () => ({
isHardwareAccount: jest.fn(() => false),
}));

let mockHasTransactionType = true;
jest.mock('../../../../../../Views/confirmations/utils/transaction', () => ({
hasTransactionType: (transactionMeta: unknown) => {
if (!transactionMeta) return false;
return mockHasTransactionType;
},
}));

jest.mock('../../../../../../../../locales/i18n', () => ({
strings: (key: string) => {
if (key === 'confirm.label.pay_with') return 'Pay with';
Expand Down Expand Up @@ -93,6 +101,7 @@ describe('PredictPayWithRow', () => {
mockIsPredictBalanceSelected = false;
mockSelectedPaymentToken = null;
mockIsHardwareAccount.mockReturnValue(false);
mockHasTransactionType = true;
});

it('renders label with payToken symbol', () => {
Expand Down Expand Up @@ -199,6 +208,56 @@ describe('PredictPayWithRow', () => {
expect(screen.getByText('Pay with USDC')).toBeOnTheScreen();
});

it('does not navigate when transactionMeta is null', () => {
mockTransactionMeta = null;

renderWithProvider(<PredictPayWithRow />);
fireEvent.press(screen.getByText('Pay with USDC'));

expect(mockNavigate).not.toHaveBeenCalled();
});

it('hides arrow icon when transactionMeta is null', () => {
mockTransactionMeta = null;

const { toJSON } = renderWithProvider(<PredictPayWithRow />);
const tree = JSON.stringify(toJSON());

expect(tree).not.toContain('ArrowDown');
});

it('applies muted background when canEdit is true', () => {
const { toJSON } = renderWithProvider(<PredictPayWithRow />);
const tree = JSON.stringify(toJSON());

expect(tree).toContain('backgroundColor');
});

it('does not apply muted background when disabled', () => {
const { toJSON } = renderWithProvider(<PredictPayWithRow disabled />);
const tree = JSON.stringify(toJSON());

expect(tree).not.toContain('backgroundColor');
});

it('does not apply muted background when transactionMeta is null', () => {
mockTransactionMeta = null;

const { toJSON } = renderWithProvider(<PredictPayWithRow />);
const tree = JSON.stringify(toJSON());

expect(tree).not.toContain('backgroundColor');
});

it('does not apply muted background for hardware accounts', () => {
mockIsHardwareAccount.mockReturnValue(true);

const { toJSON } = renderWithProvider(<PredictPayWithRow />);
const tree = JSON.stringify(toJSON());

expect(tree).not.toContain('backgroundColor');
});

it('renders predict balance first hint when external token selected', () => {
mockIsPredictBalanceSelected = false;

Expand All @@ -216,4 +275,22 @@ describe('PredictPayWithRow', () => {
screen.queryByText('Predict balance used first'),
).not.toBeOnTheScreen();
});

it('does not navigate when transaction is not predictDepositAndOrder', () => {
mockHasTransactionType = false;

renderWithProvider(<PredictPayWithRow />);
fireEvent.press(screen.getByText('Pay with USDC'));

expect(mockNavigate).not.toHaveBeenCalled();
});

it('hides arrow icon when transaction is not predictDepositAndOrder', () => {
mockHasTransactionType = false;

const { toJSON } = renderWithProvider(<PredictPayWithRow />);
const tree = JSON.stringify(toJSON());

expect(tree).not.toContain('ArrowDown');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
TextVariant,
} from '@metamask/design-system-react-native';
import { Hex } from '@metamask/utils';
import { TransactionType } from '@metamask/transaction-controller';
import { strings } from '../../../../../../../../locales/i18n';
import Icon, {
IconColor,
Expand All @@ -20,6 +21,7 @@ import Icon, {
import Routes from '../../../../../../../constants/navigation/Routes';
import { useTransactionPayToken } from '../../../../../../Views/confirmations/hooks/pay/useTransactionPayToken';
import { useTransactionMetadataRequest } from '../../../../../../Views/confirmations/hooks/transactions/useTransactionMetadataRequest';
import { hasTransactionType } from '../../../../../../Views/confirmations/utils/transaction';
import { TokenIcon } from '../../../../../../Views/confirmations/components/token-icon';
import { isHardwareAccount } from '../../../../../../../util/address';
import { POLYGON_USDCE } from '../../../../../../Views/confirmations/constants/predict';
Expand All @@ -39,7 +41,13 @@ export function PredictPayWithRow({
const { payToken } = useTransactionPayToken();
const transactionMeta = useTransactionMetadataRequest();
const from = transactionMeta?.txParams?.from;
const canEdit = !isHardwareAccount((from as string) ?? '') && !disabled;
const isPredictDepositAndOrder = hasTransactionType(transactionMeta, [
TransactionType.predictDepositAndOrder,
]);
const canEdit =
!isHardwareAccount((from as string) ?? '') &&
!disabled &&
isPredictDepositAndOrder;
const { isPredictBalanceSelected, selectedPaymentToken } =
usePredictPaymentToken();

Expand Down Expand Up @@ -72,7 +80,7 @@ export function PredictPayWithRow({
flexDirection={BoxFlexDirection.Row}
alignItems={BoxAlignItems.Center}
justifyContent={BoxJustifyContent.Center}
twClassName={`rounded-full py-2 pl-[9px] pr-[16px] mt-2 ${disabled ? '' : 'bg-muted'} mx-auto`}
twClassName={`rounded-full py-2 pl-[9px] pr-[16px] mt-2 ${!canEdit ? '' : 'bg-muted'} mx-auto`}
gap={3}
>
{tokenIconAddress && tokenIconChainId && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ jest.mock('../../../../../Views/confirmations/hooks/useConfirmActions', () => ({
}));

const mockClearActiveOrderTransactionId = jest.fn();
const mockRejectRequest = jest.fn();
const mockTransactions: { id: string; status: string }[] = [];

jest.mock('../../../hooks/usePredictActiveOrder', () => ({
usePredictActiveOrder: () => ({
Expand Down Expand Up @@ -121,6 +123,16 @@ jest.mock('../../../../../../core/Engine', () => ({
clearActiveOrderTransactionId: (...args: unknown[]) =>
mockClearActiveOrderTransactionId(...args),
},
ApprovalController: {
rejectRequest: (...args: unknown[]) => mockRejectRequest(...args),
},
TransactionController: {
state: {
get transactions() {
return mockTransactions;
},
},
},
},
}));

Expand Down Expand Up @@ -154,6 +166,7 @@ describe('usePredictBuyActions', () => {
mockTransitionEndCallbacks.length = 0;
mockBeforeRemoveCallbacks.length = 0;
mockAddListener.mockImplementation(createAddListenerMock());
mockTransactions.length = 0;
});

describe('mount effect', () => {
Expand Down Expand Up @@ -548,4 +561,52 @@ describe('usePredictBuyActions', () => {
expect(mockDispatch).not.toHaveBeenCalledWith(StackActions.pop());
});
});

describe('pending transaction rejection', () => {
it('rejects unapproved transactions before calling initPayWithAnyToken', () => {
mockTransactions.push(
{ id: 'tx-1', status: 'unapproved' },
{ id: 'tx-2', status: 'unapproved' },
);

renderHook(() => usePredictBuyActions(createDefaultParams()));

expect(mockRejectRequest).toHaveBeenCalledTimes(2);
expect(mockRejectRequest).toHaveBeenCalledWith('tx-1', expect.anything());
expect(mockRejectRequest).toHaveBeenCalledWith('tx-2', expect.anything());
expect(mockInitPayWithAnyToken).toHaveBeenCalledTimes(1);
});

it('does not reject already approved transactions', () => {
mockTransactions.push(
{ id: 'tx-1', status: 'confirmed' },
{ id: 'tx-2', status: 'unapproved' },
);

renderHook(() => usePredictBuyActions(createDefaultParams()));

expect(mockRejectRequest).toHaveBeenCalledTimes(1);
expect(mockRejectRequest).toHaveBeenCalledWith('tx-2', expect.anything());
});

it('proceeds with initPayWithAnyToken even if rejection throws', () => {
mockTransactions.push({ id: 'tx-1', status: 'unapproved' });
mockRejectRequest.mockImplementation(() => {
throw new Error('Already resolved');
});

renderHook(() => usePredictBuyActions(createDefaultParams()));

expect(mockInitPayWithAnyToken).toHaveBeenCalledTimes(1);
});

it('does not reject transactions when pay with any token is disabled', () => {
mockPayWithAnyTokenEnabled = false;
mockTransactions.push({ id: 'tx-1', status: 'unapproved' });

renderHook(() => usePredictBuyActions(createDefaultParams()));

expect(mockRejectRequest).not.toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
OrderPreview,
PlaceOrderParams,
} from '../../../types';
import { TransactionStatus } from '@metamask/transaction-controller';
import { providerErrors } from '@metamask/rpc-errors';
import useApprovalRequest from '../../../../../Views/confirmations/hooks/useApprovalRequest';
import { usePredictActiveOrder } from '../../../hooks/usePredictActiveOrder';
import Engine from '../../../../../../core/Engine';
Expand All @@ -17,6 +19,28 @@ import { usePredictTrading } from '../../../hooks/usePredictTrading';
import { PlaceOrderOutcome } from '../../../hooks/usePredictPlaceOrder';
import { PREDICT_ERROR_CODES } from '../../../constants/errors';
import { useConfirmActions } from '../../../../../Views/confirmations/hooks/useConfirmActions';

/**
* Rejects all unapproved transactions to prevent stale approvals from
* interfering with the new deposit-and-order transaction batch.
* Mirrors the cleanup logic in useConfirmNavigation.
*/
function rejectPendingTransactions() {
const { ApprovalController, TransactionController } = Engine.context;
const unapprovedTxs = TransactionController.state.transactions.filter(
(tx) => tx.status === TransactionStatus.unapproved,
);
for (const tx of unapprovedTxs) {
try {
ApprovalController.rejectRequest(
tx.id,
providerErrors.userRejectedRequest(),
);
} catch {
// Approval may already be resolved
}
}
}
interface UsePredictBuyActionsParams {
preview?: OrderPreview | null;
analyticsProperties: PlaceOrderParams['analyticsProperties'];
Expand Down Expand Up @@ -65,6 +89,7 @@ export const usePredictBuyActions = ({
const unsubscribe = navigation.addListener('transitionEnd', (e) => {
if (!e.data.closing && !hasInitializedPayWithAnyTokenRef.current) {
hasInitializedPayWithAnyTokenRef.current = true;
rejectPendingTransactions();
initPayWithAnyToken();
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ let mockSelectedPaymentToken: {
chainId?: string;
} | null = null;
let mockIsDepositPending = false;
let mockInsufficientPayTokenBalanceAlert: { message: string } | null = null;

let mockPredictBalance = 0;
const mockResetSelectedPaymentToken = jest.fn();

Expand Down Expand Up @@ -58,15 +58,6 @@ jest.mock('../../../hooks/usePredictDeposit', () => ({
}),
}));

jest.mock(
'../../../../../Views/confirmations/hooks/alerts/useInsufficientPayTokenBalanceAlert',
() => ({
useInsufficientPayTokenBalanceAlert: () => [
mockInsufficientPayTokenBalanceAlert,
],
}),
);

jest.mock(
'../../../../../Views/confirmations/hooks/pay/useTransactionPayData',
() => ({
Expand All @@ -91,6 +82,7 @@ const defaultParams = {
isConfirming: false,
totalPayForPredictBalance: 0,
isInputFocused: false,
hasBlockingPayAlerts: false,
};

describe('usePredictBuyConditions', () => {
Expand All @@ -107,7 +99,7 @@ describe('usePredictBuyConditions', () => {
mockIsPredictBalanceSelected = true;
mockSelectedPaymentToken = null;
mockIsDepositPending = false;
mockInsufficientPayTokenBalanceAlert = null;

mockPredictBalance = 0;
});

Expand Down Expand Up @@ -362,12 +354,12 @@ describe('usePredictBuyConditions', () => {

it('returns false when external payment token balance is insufficient', () => {
mockIsPredictBalanceSelected = false;
mockInsufficientPayTokenBalanceAlert = {
message: 'Insufficient payment token balance',
};

const { result } = renderHook(() =>
usePredictBuyConditions(defaultParams),
usePredictBuyConditions({
...defaultParams,
hasBlockingPayAlerts: true,
}),
);

expect(result.current.canPlaceBet).toBe(false);
Expand Down
Loading
Loading