Skip to content
Draft
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 @@ -31,6 +31,7 @@ import { useParams } from '../../../../../util/navigation/navUtils';
import { PayWithOption } from '../../components/confirm/confirm-component';
import { useFiatConfirm } from '../pay/useFiatConfirm';
import { useHandleHwSend } from '../../../../UI/HardwareWallet/Swaps/useHandleHwSend';
import { useTransactionPayingAccount } from './useTransactionPayingAccount';

const mockNavigate = jest.fn();
const mockGoBack = jest.fn();
Expand All @@ -55,6 +56,7 @@ jest.mock('../../../../../util/navigation/navUtils', () => ({
jest.mock('../../../../UI/HardwareWallet/Swaps/useHandleHwSend', () => ({
useHandleHwSend: jest.fn(),
}));
jest.mock('./useTransactionPayingAccount');

jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
Expand All @@ -65,6 +67,8 @@ jest.mock('@react-navigation/native', () => ({
}));

const CHAIN_ID_MOCK = '0x123';
const SOFTWARE_SIGNER_ADDRESS = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266';
const HARDWARE_PAYER_ADDRESS = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8';

// ---------- Top-level mocks (referenced by beforeEach and tests) ----------

Expand All @@ -84,6 +88,9 @@ const useTransactionMetadataRequestMock = jest.mocked(
);
const isHardwareAccountMock = jest.mocked(isHardwareAccount);
const useHandleHwSendMock = jest.mocked(useHandleHwSend);
const useTransactionPayingAccountMock = jest.mocked(
useTransactionPayingAccount,
);
const onFiatConfirmMock = jest.fn();
const useParamsMock = jest.mocked(useParams);

Expand Down Expand Up @@ -194,6 +201,7 @@ describe('useTransactionConfirm', () => {
});

isHardwareAccountMock.mockReturnValue(false);
useTransactionPayingAccountMock.mockReturnValue(undefined);

useHandleHwSendMock.mockReturnValue({
shouldDefer: jest.fn(() => false),
Expand Down Expand Up @@ -326,6 +334,32 @@ describe('useTransactionConfirm', () => {
);
});

it('waits for result when a hardware payer differs from the software signer', async () => {
useTransactionPayingAccountMock.mockReturnValue(HARDWARE_PAYER_ADDRESS);
isHardwareAccountMock.mockImplementation(
(address) => address === HARDWARE_PAYER_ADDRESS,
);
useTransactionPayQuotesMock.mockReturnValue([
{} as TransactionPayQuote<Json>,
]);
useTransactionMetadataRequestMock.mockReturnValue({
id: transactionIdMock,
chainId: CHAIN_ID_MOCK,
txParams: { from: SOFTWARE_SIGNER_ADDRESS },
} as unknown as TransactionMeta);

const { result } = renderHook();

await act(async () => {
await result.current.onConfirm();
});

expect(onApprovalConfirm).toHaveBeenCalledWith(
expect.objectContaining({ waitForResult: true }),
expect.anything(),
);
});

it('calls tryEnableEvmNetwork', async () => {
const tryEnableEvmNetworkMock = jest.fn();

Expand Down Expand Up @@ -635,7 +669,7 @@ describe('useTransactionConfirm', () => {
});

describe('isGasFeeSponsored override', () => {
it('clears isGasFeeSponsored when gasless is not supported', async () => {
it('passes gas sponsorship metadata through when gasless is not supported', async () => {
useIsGaslessSupportedMock.mockReturnValue({
isSmartTransaction: false,
isSupported: false,
Expand All @@ -658,7 +692,7 @@ describe('useTransactionConfirm', () => {

expect(onApprovalConfirm).toHaveBeenCalledWith(expect.anything(), {
txMeta: expect.objectContaining({
isGasFeeSponsored: false,
isGasFeeSponsored: true,
}),
});
});
Expand Down Expand Up @@ -691,7 +725,7 @@ describe('useTransactionConfirm', () => {
});
});

it('clears isGasFeeSponsored for revoke delegation when gasless is supported', async () => {
it('passes gas sponsorship metadata through for revoke delegation when gasless is supported', async () => {
useIsGaslessSupportedMock.mockReturnValue({
isSmartTransaction: true,
isSupported: true,
Expand All @@ -715,12 +749,12 @@ describe('useTransactionConfirm', () => {

expect(onApprovalConfirm).toHaveBeenCalledWith(expect.anything(), {
txMeta: expect.objectContaining({
isGasFeeSponsored: false,
isGasFeeSponsored: true,
}),
});
});

it('clears isGasFeeSponsored even without selectedGasFeeToken', async () => {
it('passes gas sponsorship metadata through even without selectedGasFeeToken', async () => {
useIsGaslessSupportedMock.mockReturnValue({
isSmartTransaction: false,
isSupported: false,
Expand All @@ -747,7 +781,7 @@ describe('useTransactionConfirm', () => {

expect(onApprovalConfirm).toHaveBeenCalledWith(expect.anything(), {
txMeta: expect.objectContaining({
isGasFeeSponsored: false,
isGasFeeSponsored: true,
}),
});
});
Expand Down Expand Up @@ -790,6 +824,32 @@ describe('useTransactionConfirm', () => {
});
});

it('appends the fee-token transfer to existing smart transaction batches', async () => {
useTransactionMetadataRequestMock.mockReturnValue({
id: transactionIdMock,
chainId: CHAIN_ID_MOCK,
txParams: {},
batchTransactions: [
{ data: '0xexisting', to: '0xexisting', value: '0x0' },
],
} as unknown as TransactionMeta);

const { result } = renderHook();

await act(async () => {
await result.current.onConfirm();
});

expect(onApprovalConfirm).toHaveBeenCalledWith(expect.anything(), {
txMeta: expect.objectContaining({
batchTransactions: [
expect.objectContaining({ data: '0xexisting' }),
expect.objectContaining({ data: '0xabc' }),
],
}),
});
});

it('does nothing if selectedGasFeeToken missing', async () => {
useSelectedGasFeeTokenMock.mockReturnValue(
undefined as unknown as ReturnType<typeof useSelectedGasFeeToken>,
Expand Down Expand Up @@ -856,8 +916,8 @@ describe('useTransactionConfirm', () => {
});
});

describe('handleGasless7702', () => {
it('sets isExternalSign when selectedGasFeeToken is present and not smart transaction', async () => {
describe('selected gas fee token handling', () => {
it('keeps external-sign metadata out of the confirmation request when selectedGasFeeToken is present and not smart transaction', async () => {
isSendBundleSupportedMock.mockReturnValue(Promise.resolve(false));

useSelectedGasFeeTokenMock.mockReturnValue({
Expand All @@ -871,13 +931,11 @@ describe('useTransactionConfirm', () => {
});

expect(onApprovalConfirm).toHaveBeenCalledWith(expect.anything(), {
txMeta: expect.objectContaining({
isExternalSign: true,
}),
txMeta: expect.not.objectContaining({ isExternalSign: true }),
});
});

it('sets isExternalSign when selectedGasFeeToken is present and smart transaction but the chain does not support send bundle', async () => {
it('keeps external-sign metadata out of the confirmation request when selectedGasFeeToken is present and smart transaction but the chain does not support send bundle', async () => {
isSendBundleSupportedMock.mockReturnValue(Promise.resolve(false));

useSelectedGasFeeTokenMock.mockReturnValue({
Expand All @@ -891,9 +949,32 @@ describe('useTransactionConfirm', () => {
});

expect(onApprovalConfirm).toHaveBeenCalledWith(expect.anything(), {
txMeta: expect.objectContaining({
isExternalSign: true,
}),
txMeta: expect.not.objectContaining({ isExternalSign: true }),
});
});

it('uses the software signer capability when the fee payer is a hardware account', async () => {
useTransactionPayingAccountMock.mockReturnValue(HARDWARE_PAYER_ADDRESS);
isHardwareAccountMock.mockImplementation(
(address) => address === HARDWARE_PAYER_ADDRESS,
);
useSelectedGasFeeTokenMock.mockReturnValue({
transferTransaction: { data: '0xabc' },
} as unknown as ReturnType<typeof useSelectedGasFeeToken>);
useTransactionMetadataRequestMock.mockReturnValue({
id: transactionIdMock,
chainId: CHAIN_ID_MOCK,
txParams: { from: SOFTWARE_SIGNER_ADDRESS },
} as unknown as TransactionMeta);

const { result } = renderHook();

await act(async () => {
await result.current.onConfirm();
});

expect(onApprovalConfirm).toHaveBeenCalledWith(expect.anything(), {
txMeta: expect.not.objectContaining({ isExternalSign: true }),
});
});

Expand All @@ -913,16 +994,21 @@ describe('useTransactionConfirm', () => {
});
});

it('does nothing if isGasFeeTokenIgnoredIfBalance', async () => {
it('keeps the confirmation request free of client-side external-sign hints when the fee token is ignored for native balance', async () => {
isSendBundleSupportedMock.mockReturnValue(Promise.resolve(false));

useIsGaslessSupportedMock.mockReturnValue({
isSupported: false,
isSmartTransaction: false,
pending: false,
});
useSelectedGasFeeTokenMock.mockReturnValue({
transferTransaction: { data: '0xabc' },
} as unknown as ReturnType<typeof useSelectedGasFeeToken>);

useTransactionMetadataRequestMock.mockReturnValue({
id: transactionIdMock,
isGasFeeSponsored: true,
isGasFeeTokenIgnoredIfBalance: true,
txParams: { from: SOFTWARE_SIGNER_ADDRESS },
} as unknown as TransactionMeta);

const { result } = renderHook();
Expand All @@ -937,16 +1023,8 @@ describe('useTransactionConfirm', () => {
});
});

describe('isExternalSign revert for unsupported accounts', () => {
// Regression: on gas-sponsorship chains (e.g. Monad, SEI) the
// TransactionController sets `isExternalSign = true` from
// `isGasFeeSponsored` during gas simulation regardless of account type.
// For hardware wallets no relay is eligible (HW cannot hold an EIP-7702
// delegation), so leaving the flag set skips device signing and an empty
// `0x` payload reaches eth_sendRawTransaction. The fix reverts the flag
// whenever gasless sponsorship cannot apply for the account/chain.

it('reverts isExternalSign when gasless is unsupported (hardware wallet on sponsored chain)', async () => {
describe('isExternalSign handling', () => {
it('keeps the confirmation request free of external-sign hints when gasless is unsupported (hardware wallet on sponsored chain)', async () => {
isHardwareAccountMock.mockReturnValue(true);
useIsGaslessSupportedMock.mockReturnValue({
isSupported: false,
Expand All @@ -957,7 +1035,6 @@ describe('useTransactionConfirm', () => {
id: transactionIdMock,
chainId: CHAIN_ID_MOCK,
isGasFeeSponsored: true,
isExternalSign: true,
txParams: { from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' },
} as unknown as TransactionMeta);

Expand All @@ -968,14 +1045,11 @@ describe('useTransactionConfirm', () => {
});

expect(onApprovalConfirm).toHaveBeenCalledWith(expect.anything(), {
txMeta: expect.objectContaining({
isExternalSign: false,
isGasFeeSponsored: false,
}),
txMeta: expect.not.objectContaining({ isExternalSign: true }),
});
});

it('keeps isExternalSign when gasless is supported (EOA relay path intact)', async () => {
it('keeps the confirmation request free of external-sign hints when gasless is supported (EOA relay path intact)', async () => {
isHardwareAccountMock.mockReturnValue(false);
useIsGaslessSupportedMock.mockReturnValue({
isSupported: true,
Expand All @@ -986,7 +1060,6 @@ describe('useTransactionConfirm', () => {
id: transactionIdMock,
chainId: CHAIN_ID_MOCK,
isGasFeeSponsored: true,
isExternalSign: true,
txParams: { from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' },
} as unknown as TransactionMeta);

Expand All @@ -997,10 +1070,7 @@ describe('useTransactionConfirm', () => {
});

expect(onApprovalConfirm).toHaveBeenCalledWith(expect.anything(), {
txMeta: expect.objectContaining({
isExternalSign: true,
isGasFeeSponsored: true,
}),
txMeta: expect.not.objectContaining({ isExternalSign: true }),
});
});
});
Expand Down Expand Up @@ -1086,6 +1156,39 @@ describe('useTransactionConfirm', () => {
expect(onApprovalConfirm).not.toHaveBeenCalled();
});

it('forwards sponsored metadata to the hardware signer without client-side external-sign hints', async () => {
const spy = setupHwSend();
useIsGaslessSupportedMock.mockReturnValue({
isSupported: false,
isSmartTransaction: false,
pending: false,
});
useTransactionMetadataRequestMock.mockReturnValue({
id: transactionIdMock,
chainId: CHAIN_ID_MOCK,
type: TransactionType.simpleSend,
isGasFeeSponsored: true,
txParams: {
from: HARDWARE_PAYER_ADDRESS,
to: SOFTWARE_SIGNER_ADDRESS,
value: '0x64',
},
} as unknown as TransactionMeta);

const { result } = renderHook();

await act(async () => {
await result.current.onConfirm();
});

expect(spy).toHaveBeenCalledWith(
expect.objectContaining({
isGasFeeSponsored: true,
}),
);
expect(onApprovalConfirm).not.toHaveBeenCalled();
});

it('ERC-20 send (tokenMethodTransfer): routes through handleHwSend', async () => {
// The decoded amount + symbol now live inside useHandleHwSend; here we
// assert the branch fires for ERC-20 transfers and the prepared
Expand Down
Loading
Loading