Skip to content
This repository has been archived by the owner on Jan 13, 2025. It is now read-only.

Commit

Permalink
A useSignIn() hook for accessing the Sign In With Solana feature
Browse files Browse the repository at this point in the history
  • Loading branch information
steveluscher committed Jul 12, 2024
1 parent e7b3dac commit bcf481a
Show file tree
Hide file tree
Showing 6 changed files with 401 additions and 0 deletions.
40 changes: 40 additions & 0 deletions .changeset/fuzzy-lizards-end.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@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 (
<button
onClick={async () => {
try {
const { account, signedMessage, signature } = await signIn({
requestId: csrfToken,
});
// Authenticate the user, typically on the server, by verifying that
// `signedMessage` was signed by the person who holds the private key for
// `account.publicKey`.
//
// Authorize the user, also on the server, by decoding `signedMessage` as the
// text of a Sign In With Solana message, verifying that it was not modified
// from the values your application expects, and that its content is sufficient
// to grant them access.
window.alert(`You are now signed in with the address ${account.address}`);
} catch (e) {
console.error('Failed to sign in', e);
}
}}
>
Sign In
</button>
);
}
```
39 changes: 39 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,45 @@ 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 [&lsquo;Sign In With Solana&rsquo;](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 (
<button
onClick={async () => {
try {
const { account, signedMessage, signature } = await signIn({
requestId: csrfToken,
});
// Authenticate the user, typically on the server, by verifying that
// `signedMessage` was signed by the person who holds the private key for
// `account.publicKey`.
//
// Authorize the user, also on the server, by decoding `signedMessage` as the
// text of a Sign In With Solana message, verifying that it was not modified
// from the values your application expects, and that its content is sufficient
// to grant them access.
window.alert(`You are now signed in with the address ${account.address}`);
} catch (e) {
console.error('Failed to sign in', e);
}
}}
>
Sign In
</button>
);
}
```

### `useWalletAccountMessageSigner(uiWalletAccount)`

Given a `UiWalletAccount`, this hook returns an object that conforms to the `MessageModifyingSigner` interface of `@solana/signers`.
Expand Down
177 changes: 177 additions & 0 deletions packages/react/src/__tests__/useSignIn-test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof useSignIn>;
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<SolanaSignInInput, 'address'>) => 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' });
});
});
});
});
61 changes: 61 additions & 0 deletions packages/react/src/__typetests__/useSignIn-typetest.ts
Original file line number Diff line number Diff line change
@@ -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'),
});
}
}
}
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './useSignAndSendTransaction';
export * from './useSignIn';
export * from './useSignMessage';
export * from './useSignTransaction';
export * from './useWalletAccountMessageSigner';
Expand Down
Loading

0 comments on commit bcf481a

Please sign in to comment.