Skip to content

Commit

Permalink
A series of Solana React hooks for Wallet Standard wallets (#2772)
Browse files Browse the repository at this point in the history
  • Loading branch information
steveluscher authored Jun 13, 2024
1 parent 4a9b544 commit 8fe4551
Show file tree
Hide file tree
Showing 13 changed files with 873 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/hot-penguins-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@solana/react': patch
---

Added a series of React hooks to which you can pass a Wallet Standard `UiWalletAccount` to extract its `signMessage`, `signTransaction`, and `signAndSendTransaction` features
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"pnpm": {
"overrides": {
"@solana/web3.js": "workspace:*",
"@wallet-standard/base": "pre",
"conventional-changelog-conventionalcommits": ">= 8.0.0",
"jsdom": "^22",
"mock-socket": "^9.3.1",
Expand Down
146 changes: 146 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,149 @@
# @solana/react

This package contains React hooks for building Solana apps.

## Hooks

### `useSignMessage(uiWalletAccount)`

Given a `UiWalletAccount`, this hook returns a function you can call to sign a byte array.

#### Arguments

A config object with the following properties:

- `message`: A `Uint8Array` of bytes to sign

#### Returns

An object with the following properties:

- `signature`: The 64-byte Ed25519 signature as a `Uint8Array`
- `signedMessage`: The `Uint8Array` of bytes signed by the wallet. These bytes might differ from the input bytes if the wallet modified the message

#### Example

```tsx
import { useSignMessage } from '@solana/react';

function SignMessageButton({ account, messageBytes }) {
const signMessage = useSignMessage(account);
return (
<button
onClick={async () => {
try {
const { signature } = await signMessage({
message: messageBytes,
});
window.alert(`Signature bytes: ${signature.toString()}`);
} catch (e) {
console.error('Failed to sign message', e);
}
}}
>
Sign Message
</button>
);
}
```

> [!NOTE]
> There exists a plural version of this hook &ndash; `useSignMessages` &ndash; that accepts multiple inputs and returns an array of outputs.
### `useSignTransaction(uiWalletAccount, chain)`

Given a `UiWalletAccount` and a chain that begins with `solana:`, this hook returns a function you can call to sign a serialized transaction.

#### Arguments

A config object with the following properties:

- `options`: An object with the following properties:
- `minContextSlot`: A slot at which any blockhash/nonce in the transaction is known to exist. This may be used by the signer and/or RPC to determine the validity of the blockhashes/nonces it has observed.
- `transaction`: A `Uint8Array` of bytes that conforms to the [Solana transaction schema](https://solana.com/docs/core/transactions#transaction)

#### Returns

An object with the following properties:

- `signedTransaction`: A `Uint8Array` of bytes that conforms to the [Solana transaction schema](https://solana.com/docs/core/transactions#transaction)

#### Example

```tsx
import { useSignTransaction } from '@solana/react';

function SignTransactionButton({ account, transactionBytes }) {
const signTransaction = useSignTransaction(account, 'solana:devnet');
return (
<button
onClick={async () => {
try {
const { signedTransaction } = await signTransaction({
transaction: transactionBytes,
});
window.alert(`Signed transaction bytes: ${signedTransaction.toString()}`);
} catch (e) {
console.error('Failed to sign transaction', e);
}
}}
>
Sign Transaction
</button>
);
}
```

> [!NOTE]
> There exists a plural version of this hook &ndash; `useSignTransactions` &ndash; that accepts multiple inputs and returns an array of outputs.
### `useSignAndSendTransaction(uiWalletAccount, chain)`

Given a `UiWalletAccount` and a chain that begins with `solana:`, this hook returns a function you can call to sign and send a serialized transaction.

#### Arguments

A config object with the following properties:

- `options`: An object with the following properties:
- `minContextSlot`: A slot at which any blockhash/nonce in the transaction is known to exist. This may be used by the signer and/or RPC to determine the validity of the blockhashes/nonces it has observed.
- `transaction`: A `Uint8Array` of bytes that conforms to the [Solana transaction schema](https://solana.com/docs/core/transactions#transaction)

#### Returns

That function returns an object with the following properties:

- `signature`: A `Uint8Array` of bytes representing the signature of the sent transaction.

#### Example

```tsx
import { getBase58Decoder } from '@solana/codecs-strings';
import { useSignAndSendTransaction } from '@solana/react';

function SignAndSendTransactionButton({ account, transactionBytes }) {
const signAndSendTransaction = useSignAndSendTransaction(account, 'solana:devnet');
return (
<button
onClick={async () => {
try {
const { signature } = await signAndSendTransaction({
transaction: transactionBytes,
});
const base58TransactionSignature = getBase58Decoder().decode(signature);
window.alert(
`View transaction: https://explorer.solana.com/tx/${base58TransactionSignature}?cluster=devnet`,
);
} catch (e) {
console.error('Failed to send transaction', e);
}
}}
>
Sign and Send Transaction
</button>
);
}
```

> [!NOTE]
> There exists a plural version of this hook &ndash; `useSignAndSendTransactions` &ndash; that accepts multiple inputs and returns an array of outputs.
7 changes: 7 additions & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@
"engine": {
"node": ">=17.4"
},
"dependencies": {
"@solana/wallet-standard-features": "^1.2.0",
"@wallet-standard/base": "pre",
"@wallet-standard/errors": "pre",
"@wallet-standard/ui": "pre",
"@wallet-standard/ui-registry": "pre"
},
"devDependencies": {
"@types/react": "^18",
"@types/react-test-renderer": "^18",
Expand Down
135 changes: 135 additions & 0 deletions packages/react/src/__tests__/useSignAndSendTransaction-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { Wallet, WalletAccount, WalletVersion } from '@wallet-standard/base';
import {
WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_CHAIN_UNSUPPORTED,
WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED,
WalletStandardError,
} from '@wallet-standard/errors';
import type { 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 { useSignAndSendTransaction } from '../useSignAndSendTransaction';

jest.mock('@wallet-standard/ui-registry');

describe('useSignAndSendTransaction', () => {
let mockSignAndSendTransaction: jest.Mock;
let mockUiWalletAccount: {
address: 'abc';
chains: ['solana:danknet'];
features: ['solana:signAndSendTransaction'];
publicKey: Uint8Array;
'~uiWalletHandle': UiWalletAccount['~uiWalletHandle'];
};
let mockWalletAccount: WalletAccount;
beforeEach(() => {
mockSignAndSendTransaction = jest.fn().mockResolvedValue([{ signature: 'abc' }]);
mockUiWalletAccount = {
address: 'abc',
chains: ['solana:danknet'] as const,
features: ['solana:signAndSendTransaction'] as const,
publicKey: new Uint8Array([1, 2, 3]),
'~uiWalletHandle': null as unknown as UiWalletAccount['~uiWalletHandle'],
};
mockWalletAccount = {
address: 'abc',
chains: ['solana:danknet'] as const,
features: ['solana:signAndSendTransaction'] as const,
publicKey: new Uint8Array([1, 2, 3]),
};
const mockWallet: Wallet = {
accounts: [mockWalletAccount],
chains: ['solana:danknet'],
features: {
['solana:signAndSendTransaction']: {
signAndSendTransaction: mockSignAndSendTransaction,
},
},
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 account that does not support the `solana:signAndSendTransaction` feature', () => {
const { result } = renderHook(() =>
useSignAndSendTransaction({ ...mockUiWalletAccount, features: ['other:feature'] }, 'solana:danknet'),
);
expect(result.__type).toBe('error');
expect(result.current).toEqual(
new WalletStandardError(WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED, {
address: 'abc',
featureName: 'solana:signAndSendTransaction',
supportedChains: ['solana:danknet'],
supportedFeatures: ['other:feature'],
}),
);
});
it('fatals when passed a wallet account that does not support the specified chain', () => {
const { result } = renderHook(() =>
useSignAndSendTransaction(
{ ...mockUiWalletAccount, chains: ['solana:basednet'] },
// @ts-expect-error Using a non-supported chain on purpose, as part of the test.
'solana:danknet',
),
);
expect(result.__type).toBe('error');
expect(result.current).toEqual(
new WalletStandardError(WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_CHAIN_UNSUPPORTED, {
address: 'abc',
chain: 'solana:danknet',
featureName: 'solana:signAndSendTransaction',
supportedChains: ['solana:basednet'],
supportedFeatures: ['solana:signAndSendTransaction'],
}),
);
});
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(() => useSignAndSendTransaction(mockUiWalletAccount, 'solana:danknet'));
expect(result.__type).toBe('error');
expect(result.current).toBe('o no');
});
describe('the function returned', () => {
it("calls the wallet's `signAndSendTransaction` implementation", async () => {
expect.assertions(2);
const { result } = renderHook(() => useSignAndSendTransaction(mockUiWalletAccount, 'solana:danknet'));
// eslint-disable-next-line jest/no-conditional-in-test
if (result.__type === 'error' || !result.current) {
throw result.current;
} else {
const signAndSendTransaction = result.current;
signAndSendTransaction({
options: { minContextSlot: 123n },
transaction: new Uint8Array([1, 2, 3]),
});
await jest.runAllTimersAsync();
signAndSendTransaction({
options: { minContextSlot: 123n },
transaction: new Uint8Array([1, 2, 3]),
});
// eslint-disable-next-line jest/no-conditional-expect
expect(mockSignAndSendTransaction).toHaveBeenCalledTimes(2);
// eslint-disable-next-line jest/no-conditional-expect
expect(mockSignAndSendTransaction).toHaveBeenCalledWith({
account: mockWalletAccount,
chain: 'solana:danknet',
options: { minContextSlot: 123 },
transaction: new Uint8Array([1, 2, 3]),
});
}
});
});
});
Loading

0 comments on commit 8fe4551

Please sign in to comment.