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