Skip to content

Commit

Permalink
A useWalletAccounts hook to get all of the connected accounts of a …
Browse files Browse the repository at this point in the history
…wallet and subscribe to changes
  • Loading branch information
steveluscher committed Feb 8, 2024
1 parent 9ce9c40 commit c14535b
Show file tree
Hide file tree
Showing 10 changed files with 351 additions and 15 deletions.
8 changes: 7 additions & 1 deletion packages/errors/src/codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
export const SOLANA_ERROR__TRANSACTION_MISSING_SIGNATURES = 1 as const;
export const SOLANA_ERROR__TRANSACTION_SIGNATURE_NOT_COMPUTABLE = 2 as const;
export const SOLANA_ERROR__RPC_INTEGER_OVERFLOW = 3 as const;
export const SOLANA_ERROR__CHAIN_NOT_SUPPORTED = 4 as const;
export const SOLANA_ERROR__WALLET_DOES_NOT_SUPPORT_CHAIN = 5 as const;
export const SOLANA_ERROR__WALLET_HAS_NO_CONNECTED_ACCOUNTS_FOR_CHAIN = 6 as const;

/**
* A union of every Solana error code
Expand All @@ -28,4 +31,7 @@ export const SOLANA_ERROR__RPC_INTEGER_OVERFLOW = 3 as const;
export type SolanaErrorCode =
| typeof SOLANA_ERROR__TRANSACTION_MISSING_SIGNATURES
| typeof SOLANA_ERROR__TRANSACTION_SIGNATURE_NOT_COMPUTABLE
| typeof SOLANA_ERROR__RPC_INTEGER_OVERFLOW;
| typeof SOLANA_ERROR__RPC_INTEGER_OVERFLOW
| typeof SOLANA_ERROR__CHAIN_NOT_SUPPORTED
| typeof SOLANA_ERROR__WALLET_DOES_NOT_SUPPORT_CHAIN
| typeof SOLANA_ERROR__WALLET_HAS_NO_CONNECTED_ACCOUNTS_FOR_CHAIN;
14 changes: 14 additions & 0 deletions packages/errors/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {
SOLANA_ERROR__CHAIN_NOT_SUPPORTED,
SOLANA_ERROR__RPC_INTEGER_OVERFLOW,
SOLANA_ERROR__TRANSACTION_MISSING_SIGNATURES,
SOLANA_ERROR__WALLET_DOES_NOT_SUPPORT_CHAIN,
SOLANA_ERROR__WALLET_HAS_NO_CONNECTED_ACCOUNTS_FOR_CHAIN,
SolanaErrorCode,
} from './codes';

Expand All @@ -16,6 +19,9 @@ export type DefaultUnspecifiedErrorContextToUndefined<T> = {
* - Don't change or remove members of an error's context.
*/
export type SolanaErrorContext = DefaultUnspecifiedErrorContextToUndefined<{
[SOLANA_ERROR__CHAIN_NOT_SUPPORTED]: {
chain: `${string}:${string}`;
};
[SOLANA_ERROR__TRANSACTION_MISSING_SIGNATURES]: {
addresses: string[];
};
Expand All @@ -27,4 +33,12 @@ export type SolanaErrorContext = DefaultUnspecifiedErrorContextToUndefined<{
path?: string;
value: bigint;
};
[SOLANA_ERROR__WALLET_DOES_NOT_SUPPORT_CHAIN]: {
chain: `${string}:${string}`;
walletName: string;
};
[SOLANA_ERROR__WALLET_HAS_NO_CONNECTED_ACCOUNTS_FOR_CHAIN]: {
chain: `${string}:${string}`;
walletName: string;
};
}>;
8 changes: 8 additions & 0 deletions packages/errors/src/messages.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import {
SOLANA_ERROR__CHAIN_NOT_SUPPORTED,
SOLANA_ERROR__RPC_INTEGER_OVERFLOW,
SOLANA_ERROR__TRANSACTION_MISSING_SIGNATURES,
SOLANA_ERROR__TRANSACTION_SIGNATURE_NOT_COMPUTABLE,
SOLANA_ERROR__WALLET_DOES_NOT_SUPPORT_CHAIN,
SOLANA_ERROR__WALLET_HAS_NO_CONNECTED_ACCOUNTS_FOR_CHAIN,
SolanaErrorCode,
} from './codes';

Expand All @@ -17,6 +20,7 @@ export const SolanaErrorMessages: Readonly<{
// TypeScript will fail to build this project if add an error code without a message.
[P in SolanaErrorCode]: string;
}> = {
[SOLANA_ERROR__CHAIN_NOT_SUPPORTED]: 'The chain `$chain` is not supported',
[SOLANA_ERROR__RPC_INTEGER_OVERFLOW]:
'The $argumentLabel argument to the `$methodName` RPC method$optionalPathLabel was ' +
'`$value`. This number is unsafe for use with the Solana JSON-RPC because it exceeds ' +
Expand All @@ -25,4 +29,8 @@ export const SolanaErrorMessages: Readonly<{
[SOLANA_ERROR__TRANSACTION_SIGNATURE_NOT_COMPUTABLE]:
"Could not determine this transaction's signature. Make sure that the transaction has " +
'been signed by its fee payer.',
[SOLANA_ERROR__WALLET_DOES_NOT_SUPPORT_CHAIN]:
"The wallet '$walletName' does not support connecting to the chain `$chain`",
[SOLANA_ERROR__WALLET_HAS_NO_CONNECTED_ACCOUNTS_FOR_CHAIN]:
"The wallet '$walletName' has no connected accounts for the chain `$chain`",
};
7 changes: 7 additions & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@
"engine": {
"node": ">=17.4"
},
"dependencies": {
"@solana/addresses": "workspace:*",
"@solana/errors": "workspace:*",
"@solana/wallet-standard-chains": "^1.1.0",
"@wallet-standard/base": "^1",
"@wallet-standard/features": "^1"
},
"devDependencies": {
"@solana/eslint-config-solana": "^1.0.2",
"@swc/jest": "^0.2.29",
Expand Down
194 changes: 194 additions & 0 deletions packages/react/src/__tests__/wallet-accounts-internal-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { address } from '@solana/addresses';
import {
SOLANA_ERROR__CHAIN_NOT_SUPPORTED,
SOLANA_ERROR__WALLET_DOES_NOT_SUPPORT_CHAIN,
SOLANA_ERROR__WALLET_HAS_NO_CONNECTED_ACCOUNTS_FOR_CHAIN,
SolanaError,
} from '@solana/errors';
import { SOLANA_CHAINS } from '@solana/wallet-standard-chains';
import { Wallet } from '@wallet-standard/base';
import { StandardEvents, StandardEventsListeners } from '@wallet-standard/features';
import { act } from 'react-test-renderer';

import { renderHook } from '../test-renderer';
import { useWalletAccounts_INTERNAL_ONLY_DO_NOT_EXPORT } from '../wallet-accounts-internal';

describe('useWalletAccounts', () => {
let emitWalletChangeEvent: StandardEventsListeners['change'];
let mockWallet: Wallet;
let walletEventSubscribers: StandardEventsListeners['change'][];
beforeEach(() => {
walletEventSubscribers = [];
emitWalletChangeEvent = e => {
walletEventSubscribers.forEach(callback => {
try {
callback(e);
} catch {
/* empty */
}
});
};
mockWallet = {
accounts: [
{
address: address('Httx5rAMNW3zA6NtXbgpnq22RdS9qK6rRBiNi8Msoc8a'),
chains: ['solana:devnet'],
features: ['solana:signMessage', 'solana:signAndSendTransaction'],
icon: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAIBAAA=',
label: 'My Test Account',
publicKey: new Uint8Array([
251, 6, 90, 16, 167, 85, 10, 206, 169, 88, 60, 180, 238, 49, 109, 108, 152, 101, 243, 178, 93,
190, 195, 73, 206, 97, 76, 131, 200, 38, 175, 179,
]),
},
{
address: address('Bho2jw9KVthJ4eXHu91gFT4pmTHWBP8kXnB1DkxvB9gx'),
chains: ['solana:devnet', 'solana:mainnet', 'solana:testnet'],
features: ['solana:signMessage', 'solana:signAndSendTransaction'],
icon: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAIBAAA=',
label: 'My Personal Account',
publicKey: new Uint8Array([
159, 8, 37, 221, 244, 25, 37, 131, 55, 40, 233, 211, 111, 235, 4, 250, 61, 170, 129, 95, 102,
117, 14, 137, 115, 154, 196, 5, 68, 224, 212, 45,
]),
},
],
chains: SOLANA_CHAINS,
features: {
[StandardEvents]: {
on(_: 'change', callback: StandardEventsListeners['change']) {
walletEventSubscribers.push(callback);
},
},
},
icon: 'data:image/svg+xml;base64,ABC',
name: 'Mock Wallet',
version: '1.0.0',
};
// 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 a wallet has no accounts (ie. is not connected)', () => {
const { result } = renderHook(() =>
useWalletAccounts_INTERNAL_ONLY_DO_NOT_EXPORT({ ...mockWallet, accounts: [] }, 'devnet'),
);
expect(result.__type).toBe('error');
expect(result.current).toEqual(
new SolanaError(SOLANA_ERROR__WALLET_HAS_NO_CONNECTED_ACCOUNTS_FOR_CHAIN, {
chain: 'solana:devnet',
walletName: 'Mock Wallet',
}),
);
});
it('fatals when a wallet has no accounts for the specified chain', () => {
const { result } = renderHook(() => useWalletAccounts_INTERNAL_ONLY_DO_NOT_EXPORT(mockWallet, 'localnet'));
expect(result.__type).toBe('error');
expect(result.current).toEqual(
new SolanaError(SOLANA_ERROR__WALLET_HAS_NO_CONNECTED_ACCOUNTS_FOR_CHAIN, {
chain: 'solana:localnet',
walletName: 'Mock Wallet',
}),
);
});
it('fatals when passed a cluster that results in a chain unsupported by Solana', () => {
const { result } = renderHook(() =>
useWalletAccounts_INTERNAL_ONLY_DO_NOT_EXPORT(
mockWallet,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
'cheese-sandwich',
),
);
expect(result.__type).toBe('error');
expect(result.current).toEqual(
new SolanaError(SOLANA_ERROR__CHAIN_NOT_SUPPORTED, {
chain: 'solana:cheese-sandwich',
}),
);
});
it('fatals when passed a cluster that is not supported by the wallet', () => {
const { result } = renderHook(() =>
useWalletAccounts_INTERNAL_ONLY_DO_NOT_EXPORT(
{ ...mockWallet, chains: ['solana:mainnet'] },
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
'devnet',
),
);
expect(result.__type).toBe('error');
expect(result.current).toEqual(
new SolanaError(SOLANA_ERROR__WALLET_DOES_NOT_SUPPORT_CHAIN, {
chain: 'solana:devnet',
walletName: 'Mock Wallet',
}),
);
});
it.each(['mainnet-beta', 'devnet', 'testnet', 'localnet'] as const)(
'does not fatal when called with supported cluster `%s`',
cluster => {
const { result } = renderHook(() =>
useWalletAccounts_INTERNAL_ONLY_DO_NOT_EXPORT(
{
...mockWallet,
accounts: [
{
...mockWallet.accounts[0],
chains: [
// eslint-disable-next-line jest/no-conditional-in-test
cluster === 'mainnet-beta' ? 'solana:mainnet' : `solana:${cluster}`,
],
},
],
},
cluster,
),
);
expect(result.__type).not.toBe('error');
},
);
it('returns only accounts that match the supplied chain', () => {
const { result } = renderHook(() => useWalletAccounts_INTERNAL_ONLY_DO_NOT_EXPORT(mockWallet, 'mainnet-beta'));
expect(result.current).toStrictEqual([mockWallet.accounts[1]]);
});
it('updates when the accounts change', () => {
const { result } = renderHook(() => useWalletAccounts_INTERNAL_ONLY_DO_NOT_EXPORT(mockWallet, 'devnet'));
act(() => {
emitWalletChangeEvent({
accounts: [
...mockWallet.accounts,
{
address: address('38rGc9Ypq1UDbgr2MLebTCYQiFKCyjsS8SF7x25XQFTu'),
chains: ['solana:devnet'],
features: ['solana:signMessage', 'solana:signAndSendTransaction'],
icon: 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAIBAAA=',
label: 'Newly Added Account',
publicKey: new Uint8Array([
31, 186, 37, 242, 101, 43, 37, 145, 54, 90, 167, 143, 185, 160, 74, 109, 72, 162, 24, 240,
54, 61, 189, 241, 199, 152, 181, 255, 39, 19, 251, 200,
]),
},
],
});
});
expect(result.current).toContainEqual(
expect.objectContaining({ address: '38rGc9Ypq1UDbgr2MLebTCYQiFKCyjsS8SF7x25XQFTu' }),
);
});
it('fatals when the wallet disconnects', () => {
const { result } = renderHook(() => useWalletAccounts_INTERNAL_ONLY_DO_NOT_EXPORT(mockWallet, 'devnet'));
act(() => {
emitWalletChangeEvent({
accounts: [],
});
});
expect(result.__type).toBe('error');
expect(result.current).toEqual(
new SolanaError(SOLANA_ERROR__WALLET_HAS_NO_CONNECTED_ACCOUNTS_FOR_CHAIN, {
chain: 'solana:devnet',
walletName: 'Mock Wallet',
}),
);
});
});
7 changes: 7 additions & 0 deletions packages/react/src/chain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { IdentifierString } from '@wallet-standard/base';

export type ChainToCluster<TChain extends IdentifierString> = TChain extends 'solana:mainnet'
? 'mainnet-beta'
: TChain extends `${string}:${infer TCluster}`
? TCluster
: never;
Empty file added packages/react/src/errors.ts
Empty file.
70 changes: 70 additions & 0 deletions packages/react/src/wallet-accounts-internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {
SOLANA_ERROR__CHAIN_NOT_SUPPORTED,
SOLANA_ERROR__WALLET_DOES_NOT_SUPPORT_CHAIN,
SOLANA_ERROR__WALLET_HAS_NO_CONNECTED_ACCOUNTS_FOR_CHAIN,
SolanaError,
} from '@solana/errors';
import { isSolanaChain, SolanaChain } from '@solana/wallet-standard-chains';
import { IdentifierString, Wallet, WalletAccount, WalletWithFeatures } from '@wallet-standard/base';
import { StandardEvents, StandardEventsFeature } from '@wallet-standard/features';
import { useCallback, useRef, useSyncExternalStore } from 'react';

import { ChainToCluster } from './chain';

function getAccountsServerSnapshot() {
return [];
}

function getSolanaChainFromCluster(cluster: ChainToCluster<SolanaChain>): SolanaChain {
const chain: IdentifierString = `solana:${cluster === 'mainnet-beta' ? 'mainnet' : cluster}`;
if (!isSolanaChain(chain)) {
throw new SolanaError(SOLANA_ERROR__CHAIN_NOT_SUPPORTED, { chain });
}
return chain;
}

function hasEventsFeature(wallet: Wallet): wallet is WalletWithFeatures<StandardEventsFeature> {
return StandardEvents in wallet.features;
}

export function useWalletAccounts_INTERNAL_ONLY_DO_NOT_EXPORT<TWallet extends Wallet>(
wallet: TWallet,
cluster: ChainToCluster<TWallet['chains'][number] & SolanaChain>,
): readonly WalletAccount[] {
const solanaChain = getSolanaChainFromCluster(cluster);
if (!wallet.chains.includes(solanaChain)) {
throw new SolanaError(SOLANA_ERROR__WALLET_DOES_NOT_SUPPORT_CHAIN, {
chain: solanaChain,
walletName: wallet.name,
});
}
const accountsRef = useRef(wallet.accounts);
const accounts = useSyncExternalStore(
/* subscribe */ useCallback(
onStoreChange => {
if (!hasEventsFeature(wallet)) {
return () => {};
}
const unsubscribe = wallet.features[StandardEvents].on('change', ({ accounts }) => {
if (accounts) {
// The presence of `accounts` among this callback's args implies a change.
accountsRef.current = accounts;
onStoreChange();
}
});
return unsubscribe;
},
[wallet],
),
/* getSnapshot */ useCallback(() => accountsRef.current, []),
/* getServerSnapshot */ getAccountsServerSnapshot,
);
const accountsForSpecifiedChain = accounts.filter(account => account.chains.includes(solanaChain));
if (!accountsForSpecifiedChain.length) {
throw new SolanaError(SOLANA_ERROR__WALLET_HAS_NO_CONNECTED_ACCOUNTS_FOR_CHAIN, {
chain: solanaChain,
walletName: wallet.name,
});
}
return accountsForSpecifiedChain;
}
3 changes: 2 additions & 1 deletion packages/react/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"jsx": "react",
"lib": [
"DOM",
"ES2015"
"ES2015",
"ES2016.Array.Include"
]
},
"display": "@solana/react",
Expand Down

0 comments on commit c14535b

Please sign in to comment.