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 @@ -49,7 +49,7 @@ export const useLedgerBasePublicKeyFetcher: UseLedgerPublicKeyFetcher = (
initLedgerTransport,
getExtendedPublicKey,
} = useLedgerContext();
const { appType, appVersion } = useActiveLedgerAppInfo();
const { appType, appVersion } = useActiveLedgerAppInfo(true);
const checkIfWalletExists = useDuplicatedWalletChecker();
const checkAddressActivity = useCheckAddressActivity();

Expand Down
140 changes: 139 additions & 1 deletion packages/common/src/utils/errors/errorHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { EthereumRpcError, ethErrors } from 'eth-rpc-errors';
import { StatusCodes, TransportStatusError } from '@ledgerhq/hw-transport';
import { Status, TransportError } from '@keystonehq/hw-transport-error';
import { CommonError } from '@core/types';
import { isWrappedError, wrapError } from './errorHelpers';
import {
isWrappedError,
wrapError,
isUserRejectionError,
} from './errorHelpers';

describe('src/utils/errors/errorHelpers', () => {
describe('#isWrappedError', () => {
Expand Down Expand Up @@ -99,4 +105,136 @@ describe('src/utils/errors/errorHelpers', () => {
}
});
});

describe('#isUserRejectionError', () => {
it('returns false for null or undefined inputs', () => {
expect(isUserRejectionError(null)).toBe(false);
expect(isUserRejectionError(undefined)).toBe(false);
});

it('returns false for non-object inputs', () => {
expect(isUserRejectionError('string')).toBe(false);
expect(isUserRejectionError(123)).toBe(false);
expect(isUserRejectionError(true)).toBe(false);
});

describe('Ledger TransportStatusError cases', () => {
it('returns true for USER_REFUSED_ON_DEVICE status code', () => {
const error = new TransportStatusError(
StatusCodes.USER_REFUSED_ON_DEVICE,
);
expect(isUserRejectionError(error)).toBe(true);
});

it('returns true for CONDITIONS_OF_USE_NOT_SATISFIED status code', () => {
const error = new TransportStatusError(
StatusCodes.CONDITIONS_OF_USE_NOT_SATISFIED,
);
expect(isUserRejectionError(error)).toBe(true);
});

it('returns true for Avalanche app rejection code 0x6986', () => {
const error = new TransportStatusError(0x6986);
expect(isUserRejectionError(error)).toBe(true);
});

it('returns false for other Ledger status codes', () => {
const error = new TransportStatusError(
StatusCodes.ALGORITHM_NOT_SUPPORTED,
);
expect(isUserRejectionError(error)).toBe(false);
});
});

describe('Keystone TransportError cases', () => {
it('returns true for PRS_PARSING_REJECTED status', () => {
const error = new TransportError(
'User rejected',
Status.PRS_PARSING_REJECTED,
);
expect(isUserRejectionError(error)).toBe(true);
});

it('returns false for other Keystone status codes', () => {
const error = new TransportError(
'Other error',
Status.ERR_DEVICE_NOT_OPENED,
);
expect(isUserRejectionError(error)).toBe(false);
});
});

describe('Generic error object cases', () => {
it('returns true for objects with code 4001', () => {
const error = {
code: 4001,
message: 'User denied transaction signature',
};
expect(isUserRejectionError(error)).toBe(true);
});

it('returns true for objects with message starting with "User rejected"', () => {
const error1 = { message: 'User rejected the request' };
const error2 = { message: 'User rejected transaction' };
const error3 = { message: 'User rejected signing' };

expect(isUserRejectionError(error1)).toBe(true);
expect(isUserRejectionError(error2)).toBe(true);
expect(isUserRejectionError(error3)).toBe(true);
});

it('returns false for objects with messages that do not start with "User rejected"', () => {
const error1 = { message: 'Something else happened' };
const error2 = { message: 'The user rejected this' }; // doesn't start with "User rejected"
const error3 = { message: 'Network error occurred' };

expect(isUserRejectionError(error1)).toBe(false);
expect(isUserRejectionError(error2)).toBe(false);
expect(isUserRejectionError(error3)).toBe(false);
});

it('returns false for objects without code 4001 or rejection message', () => {
const error1 = { code: 4000, message: 'Different error' };
const error2 = { code: 5000, message: 'Another error' };
const error3 = { message: 'Generic error message' };
const error4 = { someOtherProp: 'value' };

expect(isUserRejectionError(error1)).toBe(false);
expect(isUserRejectionError(error2)).toBe(false);
expect(isUserRejectionError(error3)).toBe(false);
expect(isUserRejectionError(error4)).toBe(false);
});

it('handles objects with null or undefined message properties', () => {
const error1 = { message: null };
const error2 = { message: undefined };
const error3 = { code: 4001, message: null };

expect(isUserRejectionError(error1)).toBe(false);
expect(isUserRejectionError(error2)).toBe(false);
expect(isUserRejectionError(error3)).toBe(true); // code 4001 should still work
});
});

describe('Edge cases', () => {
it('handles empty objects', () => {
expect(isUserRejectionError({})).toBe(false);
});

it('handles Error instances with rejection messages', () => {
const error1 = new Error('User rejected the transaction');
const error2 = new Error('Network timeout');

expect(isUserRejectionError(error1)).toBe(true);
expect(isUserRejectionError(error2)).toBe(false);
});

it('handles Error instances with code 4001', () => {
const error = new Error('Some error');
(error as any).code = 4001;

expect(isUserRejectionError(error)).toBe(true);
});
});
});
});
18 changes: 17 additions & 1 deletion packages/common/src/utils/errors/errorHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { EthereumRpcError, ethErrors } from 'eth-rpc-errors';
import { StatusCodes, TransportStatusError } from '@ledgerhq/hw-transport';
import { Status, TransportError } from '@keystonehq/hw-transport-error';
import { CommonError, ErrorCode, SwapErrorCode } from '@core/types';

export type ErrorData = {
Expand Down Expand Up @@ -45,11 +47,25 @@ export function wrapError(
};
}

const LEDGER_USER_REJECTION_STATUS_CODES = [
StatusCodes.USER_REFUSED_ON_DEVICE,
StatusCodes.CONDITIONS_OF_USE_NOT_SATISFIED,
0x6986, // "Command not allowed", used by Avalanche app. Reference: https://docs.zondax.ch/ledger-apps/polkadot/APDUSPEC#return-codes
];

export const isUserRejectionError = (err: any) => {
if (!err) {
if (!err || typeof err !== 'object') {
return false;
}

if (err instanceof TransportStatusError) {
return LEDGER_USER_REJECTION_STATUS_CODES.includes(err.statusCode);
}

if (err instanceof TransportError) {
return err.transportErrorCode === Status.PRS_PARSING_REJECTED;
}

if (typeof err === 'object') {
return err.message?.startsWith('User rejected') || err.code === 4001;
}
Expand Down
24 changes: 15 additions & 9 deletions packages/service-worker/src/vmModules/ApprovalController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
MultiApprovalParamsWithContext,
} from './models';
import { TransactionStatusEvents } from '../services/transactions/events/transactionStatusEvents';
import { caipToChainId } from '@core/common';
import { caipToChainId, isUserRejectionError } from '@core/common';

type CachedRequest = {
params: ApprovalParams;
Expand Down Expand Up @@ -225,14 +225,20 @@ export class ApprovalController implements BatchApprovalController {
});
}
} catch (err) {
resolve({
error: rpcErrors.internal({
message: 'Unable to sign the message',
data: {
originalError: err instanceof Error ? err.message : err,
},
}),
});
if (isUserRejectionError(err)) {
resolve({
error: providerErrors.userRejectedRequest(),
});
} else {
resolve({
error: rpcErrors.internal({
message: 'Unable to sign the message',
data: {
originalError: err instanceof Error ? err.message : err,
},
}),
});
}
} finally {
this.#requests.delete(action.actionId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { useWalletContext } from '../WalletProvider';
/**
* When your component needs to know the active app on the Ledger device, register it as a subscriber.
* This will cause the active app to be refreshed every 2 seconds.
*
* @param {boolean} force - If true, the active app will be refreshed even if we're not in the context of a Ledger wallet. Useful for wallet import flows.
*/
export const useActiveLedgerAppInfo = () => {
export const useActiveLedgerAppInfo = (force = false) => {
const {
appType,
appVersion,
Expand All @@ -17,14 +19,14 @@ export const useActiveLedgerAppInfo = () => {
const { isLedgerWallet } = useWalletContext();

useEffect(() => {
if (!isLedgerWallet) {
if (!isLedgerWallet && !force) {
return;
}

registerSubscriber();

return unregisterSubscriber;
}, [isLedgerWallet, registerSubscriber, unregisterSubscriber]);
}, [isLedgerWallet, registerSubscriber, unregisterSubscriber, force]);

return {
appType,
Expand Down
Loading