Skip to content

Commit

Permalink
Add a signing message dialog for Ledger users to check their device (#…
Browse files Browse the repository at this point in the history
…275)

It is not dismissable until the user either approve, reject, reload the page, or
unplug the ledger, or any other OhMyGodWhatAreYouDoing action.

Fixes #240
  • Loading branch information
hansl authored Feb 13, 2025
1 parent 18e9acb commit 74ee6c5
Show file tree
Hide file tree
Showing 5 changed files with 126 additions and 49 deletions.
64 changes: 44 additions & 20 deletions components/react/authSignerModal.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { SignModalInner } from './authSignerModal';
import { PromptSignModalInner, LedgerSignModalInner } from './authSignerModal';
import { test, expect, afterEach, describe, mock, jest } from 'bun:test';
import React from 'react';
import matchers from '@testing-library/jest-dom/matchers';
import { fireEvent, screen, cleanup, within, waitFor } from '@testing-library/react';
import { renderWithChainProvider } from '@/tests/render';
import { fireEvent, screen, cleanup, render } from '@testing-library/react';
import { renderWithChainProvider, renderWithWeb3AuthProvider } from '@/tests/render';

mock.module('next/router', () => ({
useRouter: jest.fn().mockReturnValue({
Expand All @@ -14,20 +14,24 @@ mock.module('next/router', () => ({

expect.extend(matchers);

describe('SignModalInner', () => {
describe('PromptSignModalInner', () => {
afterEach(() => {
cleanup();
});

test('should render', () => {
const wrapper = renderWithChainProvider(<SignModalInner visible={true} onClose={() => {}} />);
const wrapper = renderWithChainProvider(
<PromptSignModalInner visible={true} onClose={() => {}} />
);
expect(screen.getByText('Approve')).toBeInTheDocument();
const dialog = screen.getByRole('dialog');
expect(dialog).toBeVisible();
});

test('should not be visible initially when visible prop is false', () => {
const wrapper = renderWithChainProvider(<SignModalInner visible={false} onClose={() => {}} />);
const wrapper = renderWithChainProvider(
<PromptSignModalInner visible={false} onClose={() => {}} />
);
const dialog = screen.queryAllByRole('dialog');
expect(dialog.length).toBe(0);
});
Expand All @@ -36,7 +40,7 @@ describe('SignModalInner', () => {
let [isOpen, approved, rejected] = [true, false, false];

const wrapper = renderWithChainProvider(
<SignModalInner
<PromptSignModalInner
visible={isOpen}
onClose={() => {
isOpen = false;
Expand Down Expand Up @@ -71,7 +75,7 @@ describe('SignModalInner', () => {
let [isOpen, approved, rejected] = [true, false, false];

const wrapper = renderWithChainProvider(
<SignModalInner
<PromptSignModalInner
visible={isOpen}
onClose={() => {
isOpen = false;
Expand All @@ -92,16 +96,11 @@ describe('SignModalInner', () => {
expect(rejected).toBe(false);
});

// This test is skipped because:
// 1. The dialog element's Escape key behavior is handled natively by the browser
// and can't be easily tested in JSDOM (this test is currently failing).
// 2. JSDOM (used by Jest) doesn't fully implement dialog behaviors
// 3. While we could mock HTMLDialogElement, it wouldn't accurately test the actual browser behavior
test.skip('should close on pressing escape', () => {
test('should close on pressing escape', () => {
let [isOpen, approved, rejected] = [true, false, false];

const wrapper = renderWithChainProvider(
<SignModalInner
<PromptSignModalInner
visible={isOpen}
onClose={() => {
isOpen = false;
Expand All @@ -116,13 +115,38 @@ describe('SignModalInner', () => {
);

expect(isOpen).toBe(true);
const btn = screen.getByText('✕');
btn.focus();
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
);
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' });
expect(isOpen).toBe(false);
expect(approved).toBe(false);
expect(rejected).toBe(false);
});
});

describe('LedgerSignModalInner', () => {
afterEach(() => {
cleanup();
});

test('should render', () => {
const wrapper = render(<LedgerSignModalInner onClose={() => {}} />);
expect(screen.getByText('Ledger HSM')).toBeInTheDocument();
const dialog = screen.getByRole('dialog');
expect(dialog).toBeVisible();
});

test('should NOT close on pressing escape', () => {
let isOpen = true;
const wrapper = render(<LedgerSignModalInner onClose={() => (isOpen = false)} />);

const dialog = screen.getByRole('dialog');

expect(isOpen).toBe(true);
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' });

// onClose is called.
expect(isOpen).toBe(false);

// It does not cease to be visible.
expect(dialog).toBeVisible();
});
});
95 changes: 68 additions & 27 deletions components/react/authSignerModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { getRealLogo } from '@/utils';
import { useTheme } from '@/contexts';
import env from '@/config/env';
import { ArrowRightIcon } from '../icons';
import { objectSyntax } from '../messageSyntax';
import { objectSyntax } from '@/components';
import { MsgSend } from '@liftedinit/manifestjs/dist/codegen/cosmos/bank/v1beta1/tx';
import {
MsgCreateGroupWithPolicy,
Expand All @@ -30,9 +30,7 @@ import {
MsgSetDenomMetadata,
MsgCreateDenom,
} from '@liftedinit/manifestjs/dist/codegen/osmosis/tokenfactory/v1beta1/tx';
import { Dialog, Portal } from '@headlessui/react';
import { wallets } from 'cosmos-kit';
import { wallets as cosmosExtensionWallets } from '@cosmos-kit/cosmos-extension-metamask/cjs/cosmos-metamask-extension';
import { Dialog } from '@headlessui/react';
import { Web3AuthContext } from '@/contexts/web3AuthContext';

type DisplayDataToSignProps = {
Expand Down Expand Up @@ -104,12 +102,10 @@ const DisplayDataToSign = ({
data,
address,
className,
addressClassName,
txInfoClassName,
theme,
}: DisplayDataToSignProps & {
className?: string;
addressClassName?: string;
txInfoClassName?: string;
theme?: string;
}) => {
Expand Down Expand Up @@ -249,7 +245,8 @@ const DisplayDataToSign = ({
* @constructor
*/
export const SignModal = ({ id }: { id?: string }) => {
const { prompt, promptId } = useContext(Web3AuthContext);
const { wallet } = useWallet();
const { prompt, promptId, isSigning } = useContext(Web3AuthContext);
const [visible, setVisible] = useState(false);
const [data, setData] = useState<SignData | undefined>(undefined);

Expand All @@ -262,36 +259,81 @@ export const SignModal = ({ id }: { id?: string }) => {
}
}, [promptId, id, prompt]);

if (!prompt || !data || !visible) {
if (!isSigning || !wallet) {
return null;
}

const approve = () => prompt.resolve(true);
const reject = () => prompt.resolve(false);
const showLedgerMessage = wallet.mode === 'ledger';

const approve = () => prompt?.resolve(true);
const reject = () => prompt?.resolve(false);

if (showLedgerMessage) {
return <LedgerSignModalInner onClose={() => {}} />;
} else {
return (
<PromptSignModalInner
visible={visible}
onClose={reject}
data={data}
reject={reject}
approve={approve}
/>
);
}
};

export interface LedgerSignModalInnerProps {
onClose: () => void;
}

/**
* A signing modal that is displayed when a sign request is received, when the user
* is using a Ledger wallet. This should not be cancellable (as the user is expected to
* approve/reject on his Ledger device).
* @constructor
*/
export const LedgerSignModalInner: React.FC<LedgerSignModalInnerProps> = ({ onClose }) => {
return (
<SignModalInner
visible={visible}
onClose={reject}
data={data}
reject={reject}
approve={approve}
/>
<Dialog open onClose={onClose} className="modal modal-open top-0 right-0 z-[9999]">
<div className="fixed inset-0 backdrop-blur-sm bg-black/30" aria-hidden="true" />

<Dialog.Panel className="modal-box max-w-lg w-full dark:bg-[#1D192D] bg-[#FFFFFF] rounded-lg shadow-xl">
<h3 className="text-xl font-semibold text-[#161616] dark:text-white mb-6">Ledger HSM</h3>

<p className="mt-2 text-sm leading-6 font-light dark:text-gray-400 text-gray-600 ">
It seems you are using a Ledger hardware wallet. Please approve or reject the transaction
on your device.
</p>
</Dialog.Panel>
</Dialog>
);
};

export const SignModalInner = ({
visible,
data,
onClose,
reject,
approve,
}: {
export interface SignModalInnerProps {
visible: boolean;
data?: SignData;
onClose: () => void;
reject?: () => void;
approve?: () => void;
}

/**
* The actual signing modal (with transaction info) that is displayed when a sign request is
* received.
* @param visible Whether the modal is visible.
* @param data The transaction data to sign. This will be displayed to the user.
* @param onClose Callback when the modal is closed, whether it was approved or rejected.
* @param reject Callback when the user rejects the transaction.
* @param approve Callback when the user approves the transaction.
* @constructor
*/
export const PromptSignModalInner: React.FC<SignModalInnerProps> = ({
visible,
data,
onClose,
reject,
approve,
}) => {
const { wallet } = useWallet();
const { address } = useChain(env.chain);
Expand All @@ -300,11 +342,11 @@ export const SignModalInner = ({
const walletIconString = walletIcon?.toString() ?? '';

function handleReject() {
reject && reject();
reject?.();
onClose();
}
function handleApprove() {
approve && approve();
approve?.();
onClose();
}

Expand Down Expand Up @@ -333,7 +375,6 @@ export const SignModalInner = ({
address={address ?? ''}
theme={theme}
className="space-y-4"
addressClassName="p-3 rounded-md text-sm overflow-auto h-12 dark:bg-[#E0E0FF0A] bg-[#E0E0FF0A] dark:border-[#FFFFFF33] border-[#00000033] border"
txInfoClassName="p-3 rounded-md text-sm overflow-auto h-[32rem] dark:bg-[#E0E0FF0A] bg-[#E0E0FF0A] dark:border-[#FFFFFF33] border-[#00000033] border"
/>

Expand Down
2 changes: 1 addition & 1 deletion components/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ export * from './modal';
export * from './views';
export * from './settingsModal';
export * from './inputs';
export * from './authSignerModal';
export { SignModal } from './authSignerModal';
2 changes: 1 addition & 1 deletion contexts/web3AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export interface Web3AuthContextType {
signData: SignData;
resolve: (approved: boolean) => void;
};
promptId: string | undefined;
promptId?: string;
setPromptId: (id: string | undefined) => void;
wallets: (MainWalletBase | Web3AuthWallet)[];
isSigning: boolean;
Expand Down
12 changes: 12 additions & 0 deletions tests/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { assets as axelarAssets, chain as axelarChain } from 'chain-registry/tes
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SkipProvider } from '@/contexts/skipGoContext';
import { manifestAssets, manifestChain } from '@/config/manifestChain';
import { Web3AuthContext, Web3AuthContextType } from '@/contexts/web3AuthContext';

const defaultOptions = {
chains: [manifestChain, osmosisChain, axelarChain],
Expand All @@ -32,3 +33,14 @@ export const renderWithChainProvider = (ui: React.ReactElement, options = {}) =>
options
);
};

export const renderWithWeb3AuthProvider = (
ui: React.ReactNode,
context: Web3AuthContextType,
options = {}
) => {
return renderWithChainProvider(
<Web3AuthContext.Provider value={context}>{ui}</Web3AuthContext.Provider>,
options
);
};

0 comments on commit 74ee6c5

Please sign in to comment.