diff --git a/.changeset/fuzzy-lizards-end.md b/.changeset/fuzzy-lizards-end.md new file mode 100644 index 000000000000..957034018046 --- /dev/null +++ b/.changeset/fuzzy-lizards-end.md @@ -0,0 +1,34 @@ +--- +'@solana/react': minor +--- + +Added a `useSignIn` hook that, given a `UiWallet` or `UiWalletAccount`, returns a function that you can call to trigger a wallet's [‘Sign In With Solana’](https://phantom.app/learn/developers/sign-in-with-solana) feature. + +#### Example + +```tsx +import { useSignIn } from '@solana/react'; + +function SignInButton({ wallet }) { + const csrfToken = useCsrfToken(); + const signIn = useSignIn(wallet); + return ( + + ); +} +``` diff --git a/examples/react-app/src/components/Nav.tsx b/examples/react-app/src/components/Nav.tsx index 268d5bc382d7..cbdc44b064ed 100644 --- a/examples/react-app/src/components/Nav.tsx +++ b/examples/react-app/src/components/Nav.tsx @@ -3,10 +3,14 @@ import { useContext, useTransition } from 'react'; import { ChainContext } from '../context/ChainContext'; import { ConnectWalletMenu } from './ConnectWalletMenu'; +import { SignInMenu } from './SignInMenu'; +import { useWallets } from '@wallet-standard/react'; +import { SolanaSignIn } from '@solana/wallet-standard-features'; export function Nav() { const { displayName: currentChainName, chain, setChain } = useContext(ChainContext); const [isPending, startTransition] = useTransition(); + const wallets = useWallets(); const currentChainBadge = ( {currentChainName} @@ -24,35 +28,38 @@ export function Nav() { top="0" > - - Solana React App{' '} - {setChain ? ( - - {currentChainBadge} - - { - startTransition(() => { - setChain(value as 'solana:${string}'); - }); - }} - value={chain} - > - {process.env.REACT_EXAMPLE_APP_ENABLE_MAINNET === 'true' ? ( - - Mainnet Beta - - ) : null} - Devnet - Testnet - - - - ) : ( - currentChainBadge - )} - + + + Solana React App{' '} + {setChain ? ( + + {currentChainBadge} + + { + startTransition(() => { + setChain(value as 'solana:${string}'); + }); + }} + value={chain} + > + {process.env.REACT_EXAMPLE_APP_ENABLE_MAINNET === 'true' ? ( + + Mainnet Beta + + ) : null} + Devnet + Testnet + + + + ) : ( + currentChainBadge + )} + + Connect Wallet + Sign In ); diff --git a/examples/react-app/src/components/SignInMenu.tsx b/examples/react-app/src/components/SignInMenu.tsx new file mode 100644 index 000000000000..c89fd05b44dd --- /dev/null +++ b/examples/react-app/src/components/SignInMenu.tsx @@ -0,0 +1,81 @@ +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { Button, Callout, DropdownMenu } from '@radix-ui/themes'; +import { SolanaSignIn } from '@solana/wallet-standard-features'; +import type { UiWallet } from '@wallet-standard/react'; +import { useWallets } from '@wallet-standard/react'; +import { useContext, useRef, useState, useTransition } from 'react'; + +import { SelectedWalletAccountContext } from '../context/SelectedWalletAccountContext'; +import { ErrorDialog } from './ErrorDialog'; +import { SignInMenuItem } from './SignInMenuItem'; +import { ErrorBoundary } from 'react-error-boundary'; +import { UnconnectableWalletMenuItem } from './UnconnectableWalletMenuItem'; + +type Props = Readonly<{ + children: React.ReactNode; +}>; + +export function SignInMenu({ children }: Props) { + const { current: NO_ERROR } = useRef(Symbol()); + const wallets = useWallets(); + const [_, setSelectedWalletAccount] = useContext(SelectedWalletAccountContext); + const [error, setError] = useState(NO_ERROR); + const [forceClose, setForceClose] = useState(false); + const [_isPending, startTransition] = useTransition(); + function renderItem(wallet: UiWallet) { + return ( + } + key={`wallet:${wallet.name}`} + > + { + startTransition(() => { + setSelectedWalletAccount(account); + setForceClose(true); + }); + }} + onError={setError} + wallet={wallet} + /> + + ); + } + const walletsThatSupportSignInWithSolana = []; + for (const wallet of wallets) { + if (wallet.features.includes(SolanaSignIn)) { + walletsThatSupportSignInWithSolana.push(wallet); + } + } + return ( + <> + + + + + + {walletsThatSupportSignInWithSolana.length === 0 ? ( + + + + + + This browser has no wallets installed that support{' '} + + Sign In With Solana + + . + + + ) : ( + walletsThatSupportSignInWithSolana.map(renderItem) + )} + + + {error !== NO_ERROR ? setError(NO_ERROR)} /> : null} + + ); +} diff --git a/examples/react-app/src/components/SignInMenuItem.tsx b/examples/react-app/src/components/SignInMenuItem.tsx new file mode 100644 index 000000000000..107e95184ddb --- /dev/null +++ b/examples/react-app/src/components/SignInMenuItem.tsx @@ -0,0 +1,42 @@ +import { DropdownMenu } from '@radix-ui/themes'; +import { useSignIn } from '@solana/react'; +import type { UiWallet, UiWalletAccount } from '@wallet-standard/react'; +import React, { useCallback, useState } from 'react'; + +import { WalletMenuItemContent } from './WalletMenuItemContent'; + +type Props = Readonly<{ + onError(err: unknown): void; + onSignIn(account: UiWalletAccount | undefined): void; + wallet: UiWallet; +}>; + +export function SignInMenuItem({ onSignIn, onError, wallet }: Props) { + const signIn = useSignIn(wallet); + const [isSigningIn, setIsSigningIn] = useState(false); + const handleSignInClick = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + try { + setIsSigningIn(true); + try { + const { account } = await signIn({ + domain: window.location.host, + statement: 'You will enjoy being signed in.', + }); + onSignIn(account); + } finally { + setIsSigningIn(false); + } + } catch (e) { + onError(e); + } + }, + [signIn, onSignIn, onError], + ); + return ( + + + + ); +} diff --git a/packages/react/README.md b/packages/react/README.md index a50a64365bdb..7435b95cb5a1 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -18,6 +18,39 @@ This package contains React hooks for building Solana apps. ## Hooks +### `useSignIn(uiWalletAccount, chain)` + +Given a `UiWallet` or `UiWalletAccount` this hook returns a function that you can call to trigger a wallet's [‘Sign In With Solana’](https://phantom.app/learn/developers/sign-in-with-solana) feature. + +#### Example + +```tsx +import { useSignIn } from '@solana/react'; + +function SignInButton({ wallet }) { + const csrfToken = useCsrfToken(); + const signIn = useSignIn(wallet); + return ( + + ); +} +``` + ### `useWalletAccountMessageSigner(uiWalletAccount)` Given a `UiWalletAccount`, this hook returns an object that conforms to the `MessageModifyingSigner` interface of `@solana/signers`. diff --git a/packages/react/src/__tests__/useSignIn-test.ts b/packages/react/src/__tests__/useSignIn-test.ts new file mode 100644 index 000000000000..c1b80dc388e5 --- /dev/null +++ b/packages/react/src/__tests__/useSignIn-test.ts @@ -0,0 +1,177 @@ +import { SolanaSignInInput } from '@solana/wallet-standard-features'; +import type { Wallet, WalletAccount, WalletVersion } from '@wallet-standard/base'; +import { + WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED, + WALLET_STANDARD_ERROR__FEATURES__WALLET_FEATURE_UNIMPLEMENTED, + WalletStandardError, +} from '@wallet-standard/errors'; +import type { UiWallet, UiWalletAccount } from '@wallet-standard/ui'; +import { + getWalletAccountForUiWalletAccount_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, + getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, +} from '@wallet-standard/ui-registry'; + +import { renderHook } from '../test-renderer'; +import { useSignIn } from '../useSignIn'; + +jest.mock('@wallet-standard/ui-registry'); + +describe('useSignIn', () => { + let mockSignIn: jest.Mock; + let mockUiWallet: UiWallet; + let mockUiWalletAccount: { + address: 'abc'; + chains: []; + features: ['solana:signIn']; + publicKey: Uint8Array; + '~uiWalletHandle': UiWalletAccount['~uiWalletHandle']; + }; + let mockWallet: Wallet; + let mockWalletAccount: WalletAccount; + beforeEach(() => { + mockSignIn = jest.fn().mockResolvedValue([{ signature: 'abc' }]); + mockUiWalletAccount = { + address: 'abc', + chains: [] as const, + features: ['solana:signIn'] as const, + publicKey: new Uint8Array([1, 2, 3]), + '~uiWalletHandle': null as unknown as UiWalletAccount['~uiWalletHandle'], + }; + mockUiWallet = { + accounts: [mockUiWalletAccount] as const, + chains: [] as const, + features: ['solana:signIn'] as const, + icon: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAIBAAA=', + name: 'Mock Wallet', + version: '1.0.0' as WalletVersion, + '~uiWalletHandle': null as unknown as UiWalletAccount['~uiWalletHandle'], + }; + mockWalletAccount = { + address: 'abc', + chains: [] as const, + features: ['solana:signIn'] as const, + publicKey: new Uint8Array([1, 2, 3]), + }; + mockWallet = { + accounts: [mockWalletAccount], + chains: [], + features: { + ['solana:signIn']: { + signIn: mockSignIn, + }, + }, + icon: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAIBAAA=', + name: 'Mock Wallet', + version: '1.0.0' as WalletVersion, + }; + jest.mocked(getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED).mockReturnValue(mockWallet); + jest.mocked(getWalletAccountForUiWalletAccount_DO_NOT_USE_OR_YOU_WILL_BE_FIRED).mockReturnValue( + mockWalletAccount, + ); + // Suppresses console output when an `ErrorBoundary` is hit. + // See https://stackoverflow.com/a/72632884/802047 + jest.spyOn(console, 'error').mockImplementation(); + jest.spyOn(console, 'warn').mockImplementation(); + }); + it('fatals when passed a wallet that does not support the `solana:signIn` feature', () => { + jest.mocked(getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED).mockReturnValue({ + ...mockWallet, + features: { ['other:feature']: {} }, + }); + const { result } = renderHook(() => useSignIn(mockUiWallet)); + expect(result.__type).toBe('error'); + expect(result.current).toEqual( + new WalletStandardError(WALLET_STANDARD_ERROR__FEATURES__WALLET_FEATURE_UNIMPLEMENTED, { + featureName: 'solana:signIn', + supportedChains: [], + supportedFeatures: ['other:feature'], + walletName: 'Mock Wallet', + }), + ); + }); + it('fatals when passed a wallet account that does not support the `solana:signIn` feature', () => { + const { result } = renderHook(() => useSignIn({ ...mockUiWalletAccount, features: ['other:feature'] })); + expect(result.__type).toBe('error'); + expect(result.current).toEqual( + new WalletStandardError(WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED, { + address: 'abc', + featureName: 'solana:signIn', + supportedChains: [], + supportedFeatures: ['other:feature'], + }), + ); + }); + it('fatals when the wallet account lookup for the supplied React wallet account fails', () => { + jest.mocked(getWalletAccountForUiWalletAccount_DO_NOT_USE_OR_YOU_WILL_BE_FIRED).mockImplementation(() => { + throw 'o no'; + }); + const { result } = renderHook(() => useSignIn(mockUiWalletAccount)); + expect(result.__type).toBe('error'); + expect(result.current).toBe('o no'); + }); + describe('when configured with a `UiWallet`', () => { + let signIn: ReturnType; + beforeEach(() => { + const { result } = renderHook(() => useSignIn(mockUiWallet)); + // eslint-disable-next-line jest/no-conditional-in-test + if (result.__type === 'error' || !result.current) { + throw result.current; + } else { + signIn = result.current; + } + }); + describe('the function returned', () => { + it("calls the wallet's `signIn` implementation", async () => { + expect.assertions(2); + signIn({ statement: 'You will really like being signed in' }); + await jest.runAllTimersAsync(); + signIn({ statement: 'You will really like being signed in' }); + // eslint-disable-next-line jest/no-conditional-expect + expect(mockSignIn).toHaveBeenCalledTimes(2); + // eslint-disable-next-line jest/no-conditional-expect + expect(mockSignIn).toHaveBeenCalledWith({ + statement: 'You will really like being signed in', + }); + }); + }); + }); + describe('when configured with a `UiWalletAccount`', () => { + let signIn: (input?: Omit) => Promise<{ + readonly account: WalletAccount; + readonly signature: Uint8Array; + readonly signedMessage: Uint8Array; + }>; + beforeEach(() => { + const { result } = renderHook(() => useSignIn(mockUiWalletAccount)); + // eslint-disable-next-line jest/no-conditional-in-test + if (result.__type === 'error' || !result.current) { + throw result.current; + } else { + signIn = result.current; + } + }); + describe('the function returned', () => { + it("calls the wallet's `signIn` implementation", async () => { + expect.assertions(2); + signIn({ statement: 'You will really like being signed in' }); + await jest.runAllTimersAsync(); + signIn({ statement: 'You will really like being signed in' }); + // eslint-disable-next-line jest/no-conditional-expect + expect(mockSignIn).toHaveBeenCalledTimes(2); + // eslint-disable-next-line jest/no-conditional-expect + expect(mockSignIn).toHaveBeenCalledWith({ + address: 'abc', + statement: 'You will really like being signed in', + }); + }); + it('overrides any supplied `address` input with the address of the account', () => { + signIn({ + // @ts-expect-error Not allowed by TypeScript, but what if supplied anyway? + address: '123', + }); + // eslint-disable-next-line jest/no-conditional-expect + expect(mockSignIn).toHaveBeenCalledWith({ address: 'abc' }); + }); + }); + }); +}); diff --git a/packages/react/src/__typetests__/useSignIn-typetest.ts b/packages/react/src/__typetests__/useSignIn-typetest.ts new file mode 100644 index 000000000000..01fa62ff0022 --- /dev/null +++ b/packages/react/src/__typetests__/useSignIn-typetest.ts @@ -0,0 +1,61 @@ +/* eslint-disable react-hooks/rules-of-hooks */ + +import { address } from '@solana/addresses'; +import { WalletVersion } from '@wallet-standard/base'; +import { UiWalletAccount } from '@wallet-standard/ui'; + +import { useSignIn } from '../useSignIn'; + +const mockWalletAccount = { + address: address('123'), + chains: ['solana:danknet', 'bitcoin:mainnet'] as const, + features: [], + publicKey: new Uint8Array([1, 2, 3]), + '~uiWalletHandle': null as unknown as UiWalletAccount['~uiWalletHandle'], +} as const; + +const mockWallet = { + accounts: [mockWalletAccount], + chains: ['solana:danknet', 'bitcoin:mainnet'] as const, + features: [], + icon: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAIBAAA=', + name: 'Mock Wallet', + version: '1.0.0' as WalletVersion, + '~uiWalletHandle': null as unknown as UiWalletAccount['~uiWalletHandle'], +} as const; + +// [DESCRIBE] The function returned from `useSignIn` +{ + // [DESCRIBE] When created with a wallet + { + const signIn = useSignIn(mockWallet); + + // It accepts no config + { + signIn(); + } + + // It accepts an address config + { + signIn({ address: address('abc') }); + } + } + + // [DESCRIBE] When created with a wallet account + { + const signIn = useSignIn(mockWalletAccount); + + // It accepts no config + { + signIn(); + } + + // It does not accept an address config + { + signIn({ + // @ts-expect-error Address is already provided by the wallet + address: address('abc'), + }); + } + } +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 86a8c67f319d..53c3255d65ed 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,4 +1,5 @@ export * from './useSignAndSendTransaction'; +export * from './useSignIn'; export * from './useSignMessage'; export * from './useSignTransaction'; export * from './useWalletAccountMessageSigner'; diff --git a/packages/react/src/useSignIn.ts b/packages/react/src/useSignIn.ts new file mode 100644 index 000000000000..1d8a2d74bf23 --- /dev/null +++ b/packages/react/src/useSignIn.ts @@ -0,0 +1,83 @@ +import { Address } from '@solana/addresses'; +import { + SolanaSignIn, + SolanaSignInFeature, + SolanaSignInInput, + SolanaSignInOutput, +} from '@solana/wallet-standard-features'; +import { + getWalletAccountFeature, + getWalletFeature, + UiWallet, + UiWalletAccount, + UiWalletHandle, +} from '@wallet-standard/ui'; +import { + getOrCreateUiWalletAccountForStandardWalletAccount_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, + getWalletAccountForUiWalletAccount_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, + getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, +} from '@wallet-standard/ui-registry'; +import { useCallback } from 'react'; + +type Input = SolanaSignInInput; +type Output = Omit & + Readonly<{ + account: UiWalletAccount; + }>; + +/** + * Returns a function you can call to sign in to a domain + */ +export function useSignIn(uiWalletAccount: UiWalletAccount): (input?: Omit) => Promise; +export function useSignIn(uiWallet: UiWallet): (input?: Input) => Promise; +export function useSignIn(uiWalletHandle: UiWalletHandle): (input?: Input) => Promise { + const signIns = useSignIns(uiWalletHandle); + return useCallback( + async input => { + const [result] = await signIns(input); + return result; + }, + [signIns], + ); +} + +function useSignIns( + uiWalletHandle: UiWalletHandle, +): (...inputs: readonly (Input | undefined)[]) => Promise { + let signMessageFeature: SolanaSignInFeature[typeof SolanaSignIn]; + if ('address' in uiWalletHandle && typeof uiWalletHandle.address === 'string') { + getWalletAccountForUiWalletAccount_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(uiWalletHandle as UiWalletAccount); + signMessageFeature = getWalletAccountFeature( + uiWalletHandle as UiWalletAccount, + SolanaSignIn, + ) as SolanaSignInFeature[typeof SolanaSignIn]; + } else { + signMessageFeature = getWalletFeature(uiWalletHandle, SolanaSignIn) as SolanaSignInFeature[typeof SolanaSignIn]; + } + const wallet = getWalletForHandle_DO_NOT_USE_OR_YOU_WILL_BE_FIRED(uiWalletHandle); + return useCallback( + async (...inputs) => { + const inputsWithAddressAndChainId = inputs.map(input => ({ + ...input, + // Prioritize the `UiWalletAccount` address if it exists. + ...('address' in uiWalletHandle ? { address: uiWalletHandle.address as Address } : null), + })); + const results = await signMessageFeature.signIn(...inputsWithAddressAndChainId); + const resultsWithoutSignatureType = results.map( + ({ + account, + signatureType: _, // Solana signatures are always of type `ed25519` so drop this property. + ...rest + }) => ({ + ...rest, + account: getOrCreateUiWalletAccountForStandardWalletAccount_DO_NOT_USE_OR_YOU_WILL_BE_FIRED( + wallet, + account, + ), + }), + ); + return resultsWithoutSignatureType; + }, + [signMessageFeature, uiWalletHandle, wallet], + ); +}