diff --git a/.github/workflows/publish-legacy-docs.yml b/.github/workflows/publish-gh-pages.yml similarity index 62% rename from .github/workflows/publish-legacy-docs.yml rename to .github/workflows/publish-gh-pages.yml index e9b7a7f9b3c2..ef5a34af0a85 100644 --- a/.github/workflows/publish-legacy-docs.yml +++ b/.github/workflows/publish-gh-pages.yml @@ -1,4 +1,4 @@ -name: Publish Legacy Documentation +name: Publish GitHub Pages on: workflow_dispatch: @@ -18,7 +18,7 @@ env: TURBO_TEAM: ${{ secrets.TURBO_TEAM }} jobs: - compile-docs-and-publish: + compile-github-pages-and-publish: runs-on: ubuntu-latest steps: - name: Checkout @@ -27,11 +27,17 @@ jobs: - name: Install Dependencies uses: ./.github/workflows/actions/install-dependencies - - name: Compile Documentation - run: pnpm turbo run compile:docs --concurrency=100% --filter=@solana/web3.js + - name: Compile + run: pnpm turbo run compile:ghpages --concurrency=100% - - name: Deploy Documentation to Github Pages + - name: Assemble deploy directory + run: | + mkdir -p .ghpages-deploy + cp -r ./packages/library-legacy/doc/* .ghpages-deploy + cp -r ./examples/react-app/dist/ .ghpages-deploy/example/ + + - name: Deploy to Github Pages uses: peaceiris/actions-gh-pages@v3 with: github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./packages/library-legacy/doc + publish_dir: .ghpages-deploy diff --git a/.github/workflows/pull-requests.yml b/.github/workflows/pull-requests.yml index 864ea6c1a785..7dac065d0e33 100644 --- a/.github/workflows/pull-requests.yml +++ b/.github/workflows/pull-requests.yml @@ -46,6 +46,9 @@ jobs: - name: Build & Test run: pnpm build + - name: Build GitHub Pages + run: pnpm turbo run compile:ghpages --concurrency=100% + - name: Stop Test Validator if: always() && steps.start-test-validator.outcome == 'success' run: kill ${{ steps.start-test-validator.outputs.pid }} diff --git a/.gitignore b/.gitignore index eab0fbaa78e1..a5b4892e4a97 100755 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ yarn-error.log* # `solana-test-validator` .agave/ test-ledger/ + +# GitHub Pages deploy directory +.ghpages-deploy \ No newline at end of file diff --git a/examples/react-app/.eslintrc.cjs b/examples/react-app/.eslintrc.cjs new file mode 100644 index 000000000000..98f95c7f3cbd --- /dev/null +++ b/examples/react-app/.eslintrc.cjs @@ -0,0 +1,10 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: ['../../.eslintrc.js', '@solana/eslint-config-solana/react'], + ignorePatterns: ['dist', '.eslintrc.cjs'], + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + }, +}; diff --git a/examples/react-app/.gitignore b/examples/react-app/.gitignore new file mode 100644 index 000000000000..a547bf36d8d1 --- /dev/null +++ b/examples/react-app/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/react-app/LICENSE b/examples/react-app/LICENSE new file mode 100644 index 000000000000..ec09953d3c23 --- /dev/null +++ b/examples/react-app/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2023 Solana Labs, Inc + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/examples/react-app/README.md b/examples/react-app/README.md new file mode 100644 index 000000000000..003e0a70af28 --- /dev/null +++ b/examples/react-app/README.md @@ -0,0 +1,44 @@ +# @solana/example-react-app + +This is an example of how to use `@solana/web3.js` and `@solana/react` to build a React web application. + +The latest version of this code is automatically deployed to https://solana-labs.github.io/solana-web3.js/example/ + +## Features + +- Connects to browser wallets that support the Wallet Standard; one or more at a time +- Fetches and subscribes to the balance of the selected wallet +- Allows you to sign an arbitrary message using a wallet account +- Allows you to make a transfer from the selected wallet to any other connected wallet + +## Developing + +Start a server in development mode. + +```shell +pnpm install +pnpm turbo compile:js compile:typedefs +pnpm dev +``` + +Press o + Enter to open the app in a browser. Edits to the source code will automatically reload the app. + +## Building for deployment + +Build a static bundle and HTML for deployment to a webserver. + +```shell +pnpm install +pnpm turbo build +``` + +The contents of the `dist/` directory can now be uploaded to a webserver. + +## Enabling Mainnet-Beta + +Access to this cluster is typically blocked by [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) rules, so it is disabled in the example app by default. To enable it, start the server or compile the application with the `REACT_EXAMPLE_APP_ENABLE_MAINNET` environment variable set to `"true"`. + +```shell +REACT_EXAMPLE_APP_ENABLE_MAINNET=true pnpm dev +REACT_EXAMPLE_APP_ENABLE_MAINNET=true pnpm build +``` diff --git a/examples/react-app/index.html b/examples/react-app/index.html new file mode 100644 index 000000000000..5993061c2c52 --- /dev/null +++ b/examples/react-app/index.html @@ -0,0 +1,13 @@ + + + + + + + Solana React Example App + + +
+ + + diff --git a/examples/react-app/package.json b/examples/react-app/package.json new file mode 100644 index 000000000000..e44acea2c060 --- /dev/null +++ b/examples/react-app/package.json @@ -0,0 +1,36 @@ +{ + "name": "@solana/example-react-app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/themes": "^3.0.5", + "@solana-program/system": "^0.3.2", + "@solana/react": "workspace:*", + "@solana/web3.js": "workspace:@solana/web3.js-experimental@*", + "@wallet-standard/core": "pre", + "@wallet-standard/react": "pre", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-error-boundary": "^4.0.13", + "swr": "^2.2.5" + }, + "devDependencies": { + "@solana/wallet-standard-features": "^1.2.0", + "@types/react": "^18.3", + "@types/react-dom": "^18.3", + "@typescript-eslint/eslint-plugin": "^7.13.1", + "@typescript-eslint/parser": "^7.13.1", + "@vitejs/plugin-react-swc": "^3.7.0", + "eslint-plugin-react-refresh": "^0.4.7", + "vite": "^5.3.1" + } +} diff --git a/examples/react-app/public/solanaLogoMark.svg b/examples/react-app/public/solanaLogoMark.svg new file mode 100644 index 000000000000..ed6f34d95f7e --- /dev/null +++ b/examples/react-app/public/solanaLogoMark.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/examples/react-app/src/components/Balance.tsx b/examples/react-app/src/components/Balance.tsx new file mode 100644 index 000000000000..6063357663ec --- /dev/null +++ b/examples/react-app/src/components/Balance.tsx @@ -0,0 +1,50 @@ +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { Text, Tooltip } from '@radix-ui/themes'; +import { address } from '@solana/web3.js'; +import type { UiWalletAccount } from '@wallet-standard/react'; +import { useContext, useMemo } from 'react'; +import useSWRSubscription from 'swr/subscription'; + +import { ChainContext } from '../context/ChainContext'; +import { RpcContext } from '../context/RpcContext'; +import { getErrorMessage } from '../errors'; +import { balanceSubscribe } from '../functions/balance'; +import { ErrorDialog } from './ErrorDialog'; + +type Props = Readonly<{ + account: UiWalletAccount; +}>; + +export function Balance({ account }: Props) { + const { chain } = useContext(ChainContext); + const { rpc, rpcSubscriptions } = useContext(RpcContext); + const subscribe = useMemo(() => balanceSubscribe.bind(null, rpc, rpcSubscriptions), [rpc, rpcSubscriptions]); + const { data: lamports, error } = useSWRSubscription({ address: address(account.address), chain }, subscribe); + if (error) { + return ( + <> + + + + + + + + ); + } else if (lamports == null) { + return ; + } else { + const formattedSolValue = new Intl.NumberFormat(undefined, { maximumFractionDigits: 5 }).format( + // @ts-expect-error This format string is 100% allowed now. + `${lamports}E-9`, + ); + return {`${formattedSolValue} \u25CE`}; + } +} diff --git a/examples/react-app/src/components/BaseSignMessageFeaturePanel.tsx b/examples/react-app/src/components/BaseSignMessageFeaturePanel.tsx new file mode 100644 index 000000000000..3c324c1a15d6 --- /dev/null +++ b/examples/react-app/src/components/BaseSignMessageFeaturePanel.tsx @@ -0,0 +1,102 @@ +import { Pencil1Icon } from '@radix-ui/react-icons'; +import { Blockquote, Box, Button, Code, DataList, Dialog, Flex, TextField } from '@radix-ui/themes'; +import { getBase64Decoder } from '@solana/web3.js'; +import type { ReadonlyUint8Array } from '@wallet-standard/core'; +import type { SyntheticEvent } from 'react'; +import { useRef, useState } from 'react'; + +import { ErrorDialog } from '../components/ErrorDialog'; + +type Props = Readonly<{ + signMessage(message: ReadonlyUint8Array): Promise; +}>; + +export function BaseSignMessageFeaturePanel({ signMessage }: Props) { + const { current: NO_ERROR } = useRef(Symbol()); + const [isSigningMessage, setIsSigningMessage] = useState(false); + const [error, setError] = useState(NO_ERROR); + const [lastSignature, setLastSignature] = useState(); + const [text, setText] = useState(); + return ( + +
{ + e.preventDefault(); + setError(NO_ERROR); + setIsSigningMessage(true); + try { + const signature = await signMessage(new TextEncoder().encode(text)); + setLastSignature(signature); + } catch (e) { + setLastSignature(undefined); + setError(e); + } finally { + setIsSigningMessage(false); + } + }} + > + + ) => setText(e.currentTarget.value)} + value={text} + > + + + + + + { + if (!open) { + setLastSignature(undefined); + } + }} + > + + + + {lastSignature ? ( + { + e.stopPropagation(); + }} + > + You Signed a Message! + + + Message + +
{text}
+
+
+ + Signature + + {getBase64Decoder().decode(lastSignature)} + + +
+ + + + + +
+ ) : null} +
+ {error !== NO_ERROR ? ( + setError(NO_ERROR)} title="Failed to sign message" /> + ) : null} + +
+ ); +} diff --git a/examples/react-app/src/components/ConnectWalletMenu.tsx b/examples/react-app/src/components/ConnectWalletMenu.tsx new file mode 100644 index 000000000000..31d47ed22e18 --- /dev/null +++ b/examples/react-app/src/components/ConnectWalletMenu.tsx @@ -0,0 +1,101 @@ +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { Button, Callout, DropdownMenu } from '@radix-ui/themes'; +import { StandardConnect, StandardDisconnect } from '@wallet-standard/core'; +import type { UiWallet } from '@wallet-standard/react'; +import { uiWalletAccountBelongsToUiWallet, useWallets } from '@wallet-standard/react'; +import { useContext, useRef, useState, useTransition } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; + +import { SelectedWalletAccountContext } from '../context/SelectedWalletAccountContext'; +import { ConnectWalletMenuItem } from './ConnectWalletMenuItem'; +import { ErrorDialog } from './ErrorDialog'; +import { UnconnectableWalletMenuItem } from './UnconnectableWalletMenuItem'; +import { WalletAccountIcon } from './WalletAccountIcon'; + +type Props = Readonly<{ + children: React.ReactNode; +}>; + +export function ConnectWalletMenu({ children }: Props) { + const { current: NO_ERROR } = useRef(Symbol()); + const wallets = useWallets(); + const [selectedWalletAccount, 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); + }); + }} + onDisconnect={wallet => { + if (selectedWalletAccount && uiWalletAccountBelongsToUiWallet(selectedWalletAccount, wallet)) { + startTransition(() => { + setSelectedWalletAccount(undefined); + }); + } + }} + onError={setError} + wallet={wallet} + /> + + ); + } + const walletsThatSupportStandardConnect = []; + const unconnectableWallets = []; + for (const wallet of wallets) { + if (wallet.features.includes(StandardConnect) && wallet.features.includes(StandardDisconnect)) { + walletsThatSupportStandardConnect.push(wallet); + } else { + unconnectableWallets.push(wallet); + } + } + return ( + <> + + + + + + {wallets.length === 0 ? ( + + + + + This browser has no wallets installed. + + ) : ( + <> + {walletsThatSupportStandardConnect.map(renderItem)} + {unconnectableWallets.length ? ( + <> + + {unconnectableWallets.map(renderItem)} + + ) : null} + + )} + + + {error !== NO_ERROR ? setError(NO_ERROR)} /> : null} + + ); +} diff --git a/examples/react-app/src/components/ConnectWalletMenuItem.tsx b/examples/react-app/src/components/ConnectWalletMenuItem.tsx new file mode 100644 index 000000000000..b8837f1eac73 --- /dev/null +++ b/examples/react-app/src/components/ConnectWalletMenuItem.tsx @@ -0,0 +1,91 @@ +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { DropdownMenu, ThickChevronRightIcon } from '@radix-ui/themes'; +import type { UiWallet, UiWalletAccount } from '@wallet-standard/react'; +import { useConnect, useDisconnect } from '@wallet-standard/react'; +import { useCallback, useContext } from 'react'; + +import { SelectedWalletAccountContext } from '../context/SelectedWalletAccountContext'; +import { WalletMenuItemContent } from './WalletMenuItemContent'; + +type Props = Readonly<{ + onAccountSelect(account: UiWalletAccount | undefined): void; + onDisconnect(wallet: UiWallet): void; + onError(err: unknown): void; + wallet: UiWallet; +}>; + +export function ConnectWalletMenuItem({ onAccountSelect, onDisconnect, onError, wallet }: Props) { + const [isConnecting, connect] = useConnect(wallet); + const [isDisconnecting, disconnect] = useDisconnect(wallet); + const isPending = isConnecting || isDisconnecting; + const isConnected = wallet.accounts.length > 0; + const [selectedWalletAccount] = useContext(SelectedWalletAccountContext); + const handleClick = useCallback(async () => { + try { + if (isConnected) { + await disconnect(); + onDisconnect(wallet); + } else { + const accounts = await connect(); + if (accounts[0]) { + onAccountSelect(accounts[0]); + } + } + } catch (e) { + onError(e); + } + }, [connect, disconnect, isConnected, onAccountSelect, onDisconnect, onError, wallet]); + return ( + + + + {isConnected ? ( +
+ +
+ ) : null} +
+ + Accounts + + {wallet.accounts.map(account => ( + { + onAccountSelect(account); + }} + > + {account.address.slice(0, 8)}… + + ))} + + + { + e.preventDefault(); + try { + await disconnect(); + onDisconnect(wallet); + } catch (e) { + onError(e); + } + }} + > + Disconnect + + +
+ ); +} diff --git a/examples/react-app/src/components/DisconnectButton.tsx b/examples/react-app/src/components/DisconnectButton.tsx new file mode 100644 index 000000000000..b01b33d05ef4 --- /dev/null +++ b/examples/react-app/src/components/DisconnectButton.tsx @@ -0,0 +1,51 @@ +import { ExclamationTriangleIcon, ExitIcon } from '@radix-ui/react-icons'; +import { Button, Tooltip } from '@radix-ui/themes'; +import type { UiWallet } from '@wallet-standard/react'; +import { useDisconnect } from '@wallet-standard/react'; +import { useState } from 'react'; + +import { NO_ERROR } from '../errors'; + +type Props = Readonly<{ + wallet: UiWallet; +}>; + +export function DisconnectButton({ + wallet, + ...buttonProps +}: Omit, 'color' | 'loading' | 'onClick'> & Props) { + const [isDisconnecting, disconnect] = useDisconnect(wallet); + const [lastError, setLastError] = useState(NO_ERROR); + return ( + + Error:{' '} + {lastError && typeof lastError === 'object' && 'message' in lastError + ? lastError.message + : String(lastError)} + + } + open={lastError !== NO_ERROR} + side="left" + > + + + ); +} diff --git a/examples/react-app/src/components/ErrorDialog.tsx b/examples/react-app/src/components/ErrorDialog.tsx new file mode 100644 index 000000000000..9fce582b8966 --- /dev/null +++ b/examples/react-app/src/components/ErrorDialog.tsx @@ -0,0 +1,38 @@ +import { AlertDialog, Blockquote, Button, Flex } from '@radix-ui/themes'; +import { useState } from 'react'; + +import { getErrorMessage } from '../errors'; + +type Props = Readonly<{ + error: unknown; + onClose?(): false | void; + title?: string; +}>; + +export function ErrorDialog({ error, onClose, title }: Props) { + const [isOpen, setIsOpen] = useState(true); + return ( + { + if (!open) { + if (!onClose || onClose() !== false) { + setIsOpen(false); + } + } + }} + > + + {title ?? 'We encountered the following error'} + +
{getErrorMessage(error, 'Unknown')}
+
+ + + + + +
+
+ ); +} diff --git a/examples/react-app/src/components/FeatureNotSupportedCallout.tsx b/examples/react-app/src/components/FeatureNotSupportedCallout.tsx new file mode 100644 index 000000000000..2a4bda067737 --- /dev/null +++ b/examples/react-app/src/components/FeatureNotSupportedCallout.tsx @@ -0,0 +1,23 @@ +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { Callout } from '@radix-ui/themes'; +import React from 'react'; +import type { FallbackProps } from 'react-error-boundary'; + +import { getErrorMessage } from '../errors'; + +interface Props extends Callout.RootProps, FallbackProps {} + +export function FeatureNotSupportedCallout({ + error, + resetErrorBoundary: _, + ...rootProps +}: Props): React.ReactElement { + return ( + + + + + {getErrorMessage(error, 'This account does not support this feature')} + + ); +} diff --git a/examples/react-app/src/components/FeaturePanel.tsx b/examples/react-app/src/components/FeaturePanel.tsx new file mode 100644 index 000000000000..9ccacc4ae950 --- /dev/null +++ b/examples/react-app/src/components/FeaturePanel.tsx @@ -0,0 +1,16 @@ +import { DataList } from '@radix-ui/themes'; +import React from 'react'; + +type Props = Readonly<{ + children: React.ReactNode; + label: React.ReactNode; +}>; + +export function FeaturePanel({ children, label }: Props) { + return ( + + {label} + {children} + + ); +} diff --git a/examples/react-app/src/components/Nav.tsx b/examples/react-app/src/components/Nav.tsx new file mode 100644 index 000000000000..268d5bc382d7 --- /dev/null +++ b/examples/react-app/src/components/Nav.tsx @@ -0,0 +1,59 @@ +import { Badge, Box, DropdownMenu, Flex, Heading, Spinner } from '@radix-ui/themes'; +import { useContext, useTransition } from 'react'; + +import { ChainContext } from '../context/ChainContext'; +import { ConnectWalletMenu } from './ConnectWalletMenu'; + +export function Nav() { + const { displayName: currentChainName, chain, setChain } = useContext(ChainContext); + const [isPending, startTransition] = useTransition(); + const currentChainBadge = ( + + {currentChainName} + + ); + return ( + + + + 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 + + + ); +} diff --git a/examples/react-app/src/components/SolanaSignAndSendTransactionFeaturePanel.tsx b/examples/react-app/src/components/SolanaSignAndSendTransactionFeaturePanel.tsx new file mode 100644 index 000000000000..8e208d4b4d7e --- /dev/null +++ b/examples/react-app/src/components/SolanaSignAndSendTransactionFeaturePanel.tsx @@ -0,0 +1,211 @@ +import { Blockquote, Box, Button, Dialog, Flex, Link, Select, Text, TextField } from '@radix-ui/themes'; +import { useWalletAccountTransactionSendingSigner } from '@solana/react'; +import { + address, + appendTransactionMessageInstruction, + assertIsTransactionMessageWithSingleSendingSigner, + createTransactionMessage, + getBase58Decoder, + lamports, + pipe, + setTransactionMessageFeePayerSigner, + setTransactionMessageLifetimeUsingBlockhash, + signAndSendTransactionMessageWithSigners, +} from '@solana/web3.js'; +import { getTransferSolInstruction } from '@solana-program/system'; +import { getUiWalletAccountStorageKey, type UiWalletAccount, useWallets } from '@wallet-standard/react'; +import type { SyntheticEvent } from 'react'; +import { useContext, useId, useMemo, useRef, useState } from 'react'; +import { useSWRConfig } from 'swr'; + +import { ChainContext } from '../context/ChainContext'; +import { RpcContext } from '../context/RpcContext'; +import { ErrorDialog } from './ErrorDialog'; +import { WalletMenuItemContent } from './WalletMenuItemContent'; + +type Props = Readonly<{ + account: UiWalletAccount; +}>; + +function solStringToLamports(solQuantityString: string) { + if (Number.isNaN(parseFloat(solQuantityString))) { + throw new Error('Could not parse token quantity: ' + String(solQuantityString)); + } + const numDecimals = BigInt(solQuantityString.split('.')[1]?.length ?? 0); + const bigIntLamports = BigInt(solQuantityString.replace('.', '')) * 10n ** (9n - numDecimals); + return lamports(bigIntLamports); +} + +export function SolanaSignAndSendTransactionFeaturePanel({ account }: Props) { + const { mutate } = useSWRConfig(); + const { current: NO_ERROR } = useRef(Symbol()); + const { rpc } = useContext(RpcContext); + const wallets = useWallets(); + const [isSendingTransaction, setIsSendingTransaction] = useState(false); + const [error, setError] = useState(NO_ERROR); + const [lastSignature, setLastSignature] = useState(); + const [solQuantityString, setSolQuantityString] = useState(''); + const [recipientAccountStorageKey, setRecipientAccountStorageKey] = useState(); + const recipientAccount = useMemo(() => { + if (recipientAccountStorageKey) { + for (const wallet of wallets) { + for (const account of wallet.accounts) { + if (getUiWalletAccountStorageKey(account) === recipientAccountStorageKey) { + return account; + } + } + } + } + }, [recipientAccountStorageKey, wallets]); + const { chain: currentChain, solanaExplorerClusterName } = useContext(ChainContext); + const transactionSendingSigner = useWalletAccountTransactionSendingSigner(account, currentChain); + const lamportsInputId = useId(); + const recipientSelectId = useId(); + return ( + +
{ + e.preventDefault(); + setError(NO_ERROR); + setIsSendingTransaction(true); + try { + const amount = solStringToLamports(solQuantityString); + if (!recipientAccount) { + throw new Error('The address of the recipient could not be found'); + } + const { value: latestBlockhash } = await rpc + .getLatestBlockhash({ commitment: 'confirmed' }) + .send(); + const message = pipe( + createTransactionMessage({ version: 0 }), + m => setTransactionMessageFeePayerSigner(transactionSendingSigner, m), + m => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m), + m => + appendTransactionMessageInstruction( + getTransferSolInstruction({ + amount, + destination: address(recipientAccount.address), + source: transactionSendingSigner, + }), + m, + ), + ); + assertIsTransactionMessageWithSingleSendingSigner(message); + const signature = await signAndSendTransactionMessageWithSigners(message); + mutate({ address: transactionSendingSigner.address, chain: currentChain }); + mutate({ address: recipientAccount.address, chain: currentChain }); + setLastSignature(signature); + setSolQuantityString(''); + } catch (e) { + setLastSignature(undefined); + setError(e); + } finally { + setIsSendingTransaction(false); + } + }} + > + + + + ) => + setSolQuantityString(e.currentTarget.value) + } + style={{ width: 'auto' }} + type="number" + value={solQuantityString} + > + {'\u25ce'} + + + + + To Account + + + + + + {wallets.flatMap(wallet => + wallet.accounts + .filter(({ chains }) => chains.includes(currentChain)) + .map(account => { + const key = getUiWalletAccountStorageKey(account); + return ( + + + {account.address} + + + ); + }), + )} + + + + + { + if (!open) { + setLastSignature(undefined); + } + }} + > + + + + {lastSignature ? ( + { + e.stopPropagation(); + }} + > + You transferred tokens! + + Signature: +
{getBase58Decoder().decode(lastSignature)}
+ + + View this transaction + {' '} + on Explorer + +
+ + + + + +
+ ) : null} +
+ {error !== NO_ERROR ? ( + setError(NO_ERROR)} title="Transfer failed" /> + ) : null} + +
+ ); +} diff --git a/examples/react-app/src/components/SolanaSignMessageFeaturePanel.tsx b/examples/react-app/src/components/SolanaSignMessageFeaturePanel.tsx new file mode 100644 index 000000000000..f074f92aef60 --- /dev/null +++ b/examples/react-app/src/components/SolanaSignMessageFeaturePanel.tsx @@ -0,0 +1,32 @@ +import { useWalletAccountMessageSigner } from '@solana/react'; +import type { Address } from '@solana/web3.js'; +import type { ReadonlyUint8Array } from '@wallet-standard/core'; +import type { UiWalletAccount } from '@wallet-standard/react'; +import { useCallback } from 'react'; + +import { BaseSignMessageFeaturePanel } from './BaseSignMessageFeaturePanel'; + +type Props = Readonly<{ + account: UiWalletAccount; +}>; + +export function SolanaSignMessageFeaturePanel({ account }: Props) { + const messageSigner = useWalletAccountMessageSigner(account); + const signMessage = useCallback( + async (message: ReadonlyUint8Array) => { + const [result] = await messageSigner.modifyAndSignMessages([ + { + content: message as Uint8Array, + signatures: {}, + }, + ]); + const signature = result?.signatures[account.address as Address]; + if (!signature) { + throw new Error(); + } + return signature as ReadonlyUint8Array; + }, + [account.address, messageSigner], + ); + return ; +} diff --git a/examples/react-app/src/components/UnconnectableWalletMenuItem.tsx b/examples/react-app/src/components/UnconnectableWalletMenuItem.tsx new file mode 100644 index 000000000000..746ed24256c8 --- /dev/null +++ b/examples/react-app/src/components/UnconnectableWalletMenuItem.tsx @@ -0,0 +1,34 @@ +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { Box, DropdownMenu, Text } from '@radix-ui/themes'; +import type { UiWallet } from '@wallet-standard/react'; +import { useState } from 'react'; + +import { ErrorDialog } from './ErrorDialog'; +import { WalletMenuItemContent } from './WalletMenuItemContent'; + +type Props = Readonly<{ + error: unknown; + wallet: UiWallet; +}>; + +export function UnconnectableWalletMenuItem({ error, wallet }: Props) { + const [dialogIsOpen, setDialogIsOpen] = useState(false); + return ( + <> + setDialogIsOpen(true)}> + + {wallet.name} + + + + + + {dialogIsOpen ? ( + setDialogIsOpen(false)} title="Unconnectable wallet" /> + ) : null} + + ); +} diff --git a/examples/react-app/src/components/WalletAccountIcon.tsx b/examples/react-app/src/components/WalletAccountIcon.tsx new file mode 100644 index 000000000000..d3f8e6952d3c --- /dev/null +++ b/examples/react-app/src/components/WalletAccountIcon.tsx @@ -0,0 +1,24 @@ +import type { UiWalletAccount } from '@wallet-standard/react'; +import { uiWalletAccountBelongsToUiWallet,useWallets } from '@wallet-standard/react'; +import React from 'react'; + +type Props = React.ComponentProps<'img'> & + Readonly<{ + account: UiWalletAccount; + }>; + +export function WalletAccountIcon({ account, ...imgProps }: Props) { + const wallets = useWallets(); + let icon; + if (account.icon) { + icon = account.icon; + } else { + for (const wallet of wallets) { + if (uiWalletAccountBelongsToUiWallet(account, wallet)) { + icon = wallet.icon; + break; + } + } + } + return icon ? : null; +} diff --git a/examples/react-app/src/components/WalletMenuItemContent.tsx b/examples/react-app/src/components/WalletMenuItemContent.tsx new file mode 100644 index 000000000000..3cb0d475d5e9 --- /dev/null +++ b/examples/react-app/src/components/WalletMenuItemContent.tsx @@ -0,0 +1,25 @@ +import { Avatar, Flex, Spinner, Text } from '@radix-ui/themes'; +import type { UiWallet } from '@wallet-standard/react'; +import React from 'react'; + +type Props = Readonly<{ + children?: React.ReactNode; + loading?: boolean; + wallet: UiWallet; +}>; + +export function WalletMenuItemContent({ children, loading, wallet }: Props) { + return ( + + + {wallet.name.slice(0, 1)}} + radius="none" + src={wallet.icon} + style={{ height: 18, width: 18 }} + /> + + {children ?? wallet.name} + + ); +} diff --git a/examples/react-app/src/context/ChainContext.tsx b/examples/react-app/src/context/ChainContext.tsx new file mode 100644 index 000000000000..ddb1ba9cbdd4 --- /dev/null +++ b/examples/react-app/src/context/ChainContext.tsx @@ -0,0 +1,77 @@ +import type { ClusterUrl } from '@solana/web3.js'; +import { devnet, mainnet, testnet } from '@solana/web3.js'; +import React, { createContext, useMemo, useState } from 'react'; + +import { localStorage } from '../storage'; + +const STORAGE_KEY = 'solana-example-react-app:selected-chain'; + +type Context = Readonly<{ + chain: `solana:${string}`; + displayName: string; + setChain?(chain: 'solana:${string}'): void; + solanaExplorerClusterName: 'devnet' | 'mainnet-beta' | 'testnet'; + solanaRpcSubscriptionsUrl: ClusterUrl; + solanaRpcUrl: ClusterUrl; +}>; + +const DEFAULT_CHAIN_CONFIG = Object.freeze({ + chain: 'solana:devnet', + displayName: 'Devnet', + solanaExplorerClusterName: 'devnet', + solanaRpcSubscriptionsUrl: devnet('wss://api.devnet.solana.com'), + solanaRpcUrl: devnet('https://api.devnet.solana.com'), +}); + +export const ChainContext = createContext(DEFAULT_CHAIN_CONFIG); + +export function ChainContextProvider({ children }: { children: React.ReactNode }) { + const [chain, setChain] = useState(() => localStorage.getItem(STORAGE_KEY) ?? 'solana:devnet'); + const contextValue = useMemo(() => { + switch (chain) { + // @ts-expect-error Intentional fall through + case 'solana:mainnet': + if (process.env.REACT_EXAMPLE_APP_ENABLE_MAINNET === 'true') { + return { + chain: 'solana:mainnet', + displayName: 'Mainnet Beta', + solanaExplorerClusterName: 'mainnet-beta', + solanaRpcSubscriptionsUrl: mainnet('wss://api.mainnet-beta.solana.com'), + solanaRpcUrl: mainnet('https://api.mainnet-beta.solana.com'), + }; + } + // falls through + case 'solana:testnet': + return { + chain: 'solana:testnet', + displayName: 'Testnet', + solanaExplorerClusterName: 'testnet', + solanaRpcSubscriptionsUrl: testnet('wss://api.testnet.solana.com'), + solanaRpcUrl: testnet('https://api.testnet.solana.com'), + }; + case 'solana:devnet': + default: + if (chain !== 'solana:devnet') { + localStorage.removeItem(STORAGE_KEY); + console.error(`Unrecognized chain \`${chain}\``); + } + return DEFAULT_CHAIN_CONFIG; + } + }, [chain]); + return ( + ({ + ...contextValue, + setChain(chain) { + localStorage.setItem(STORAGE_KEY, chain); + setChain(chain); + }, + }), + [contextValue], + )} + > + {children} + + ); +} diff --git a/examples/react-app/src/context/RpcContext.tsx b/examples/react-app/src/context/RpcContext.tsx new file mode 100644 index 000000000000..dec8c44b9dbd --- /dev/null +++ b/examples/react-app/src/context/RpcContext.tsx @@ -0,0 +1,35 @@ +import type { Rpc, RpcSubscriptions, SolanaRpcApiMainnet, SolanaRpcSubscriptionsApi } from '@solana/web3.js'; +import { createSolanaRpc, createSolanaRpcSubscriptions, devnet } from '@solana/web3.js'; +import type { ReactNode } from 'react'; +import { createContext, useContext, useMemo } from 'react'; + +import { ChainContext } from './ChainContext'; + +export const RpcContext = createContext<{ + rpc: Rpc; // Limit the API to only those methods found on Mainnet (ie. not `requestAirdrop`) + rpcSubscriptions: RpcSubscriptions; +}>({ + rpc: createSolanaRpc(devnet('https://api.devnet.solana.com')), + rpcSubscriptions: createSolanaRpcSubscriptions(devnet('wss://api.devnet.solana.com')), +}); + +type Props = Readonly<{ + children: ReactNode; +}>; + +export function RpcContextProvider({ children }: Props) { + const { solanaRpcSubscriptionsUrl, solanaRpcUrl } = useContext(ChainContext); + return ( + ({ + rpc: createSolanaRpc(solanaRpcUrl), + rpcSubscriptions: createSolanaRpcSubscriptions(solanaRpcSubscriptionsUrl), + }), + [solanaRpcSubscriptionsUrl, solanaRpcUrl], + )} + > + {children} + + ); +} diff --git a/examples/react-app/src/context/SelectedWalletAccountContext.tsx b/examples/react-app/src/context/SelectedWalletAccountContext.tsx new file mode 100644 index 000000000000..de021271ecfb --- /dev/null +++ b/examples/react-app/src/context/SelectedWalletAccountContext.tsx @@ -0,0 +1,111 @@ +import type { UiWallet, UiWalletAccount } from '@wallet-standard/react'; +import { + getUiWalletAccountStorageKey, + uiWalletAccountBelongsToUiWallet, + uiWalletAccountsAreSame, + useWallets, +} from '@wallet-standard/react'; +import { createContext, useEffect, useMemo, useState } from 'react'; + +import { localStorage } from '../storage'; + +type State = UiWalletAccount | undefined; + +const STORAGE_KEY = 'solana-wallet-standard-example-react:selected-wallet-and-address'; + +export const SelectedWalletAccountContext = createContext< + readonly [selectedWalletAccount: State, setSelectedWalletAccount: React.Dispatch>] +>([ + undefined /* selectedWalletAccount */, + function setSelectedWalletAccount() { + /* empty */ + }, +]); + +let wasSetterInvoked = false; +function getSavedWalletAccount(wallets: readonly UiWallet[]): UiWalletAccount | undefined { + if (wasSetterInvoked) { + // After the user makes an explicit choice of wallet, stop trying to auto-select the + // saved wallet, if and when it appears. + return; + } + const savedWalletNameAndAddress = localStorage.getItem(STORAGE_KEY); + if (!savedWalletNameAndAddress || typeof savedWalletNameAndAddress !== 'string') { + return; + } + const [savedWalletName, savedAccountAddress] = savedWalletNameAndAddress.split(':'); + if (!savedWalletName || !savedAccountAddress) { + return; + } + for (const wallet of wallets) { + if (wallet.name === savedWalletName) { + for (const account of wallet.accounts) { + if (account.address === savedAccountAddress) { + return account; + } + } + } + } +} + +/** + * Saves the selected wallet account's storage key to the browser's local storage. In future + * sessions it will try to return that same wallet account, or at least one from the same brand of + * wallet if the wallet from which it came is still in the Wallet Standard registry. + */ +export function SelectedWalletAccountContextProvider({ children }: { children: React.ReactNode }) { + const wallets = useWallets(); + const [selectedWalletAccount, setSelectedWalletAccountInternal] = useState(() => + getSavedWalletAccount(wallets), + ); + const setSelectedWalletAccount: React.Dispatch> = setStateAction => { + setSelectedWalletAccountInternal(prevSelectedWalletAccount => { + wasSetterInvoked = true; + const nextWalletAccount = + typeof setStateAction === 'function' ? setStateAction(prevSelectedWalletAccount) : setStateAction; + const accountKey = nextWalletAccount ? getUiWalletAccountStorageKey(nextWalletAccount) : undefined; + if (accountKey) { + localStorage.setItem(STORAGE_KEY, accountKey); + } else { + localStorage.removeItem(STORAGE_KEY); + } + return nextWalletAccount; + }); + }; + useEffect(() => { + const savedWalletAccount = getSavedWalletAccount(wallets); + if (savedWalletAccount) { + setSelectedWalletAccountInternal(savedWalletAccount); + } + }, [wallets]); + const walletAccount = useMemo(() => { + if (selectedWalletAccount) { + for (const uiWallet of wallets) { + for (const uiWalletAccount of uiWallet.accounts) { + if (uiWalletAccountsAreSame(selectedWalletAccount, uiWalletAccount)) { + return uiWalletAccount; + } + } + if (uiWalletAccountBelongsToUiWallet(selectedWalletAccount, uiWallet) && uiWallet.accounts[0]) { + // If the selected account belongs to this connected wallet, at least, then + // select one of its accounts. + return uiWallet.accounts[0]; + } + } + } + }, [selectedWalletAccount, wallets]); + useEffect(() => { + // If there is a selected wallet account but the wallet to which it belongs has since + // disconnected, clear the selected wallet. + if (selectedWalletAccount && !walletAccount) { + setSelectedWalletAccountInternal(undefined); + } + }, [selectedWalletAccount, walletAccount]); + return ( + [walletAccount, setSelectedWalletAccount], [walletAccount])} + > + {children} + + ); +} diff --git a/examples/react-app/src/errors.tsx b/examples/react-app/src/errors.tsx new file mode 100644 index 000000000000..fe2d6e059577 --- /dev/null +++ b/examples/react-app/src/errors.tsx @@ -0,0 +1,66 @@ +import { Code, Flex, Text } from '@radix-ui/themes'; +import { + isWalletStandardError, + WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_CHAIN_UNSUPPORTED, + WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED, + WALLET_STANDARD_ERROR__FEATURES__WALLET_FEATURE_UNIMPLEMENTED, +} from '@wallet-standard/core'; +import React from 'react'; + +export const NO_ERROR = Symbol(); + +export function getErrorMessage(err: unknown, fallbackMessage: React.ReactNode): React.ReactNode { + if (isWalletStandardError(err, WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_FEATURE_UNIMPLEMENTED)) { + return ( + <> + This account does not support the {err.context.featureName} feature + + ); + } else if (isWalletStandardError(err, WALLET_STANDARD_ERROR__FEATURES__WALLET_FEATURE_UNIMPLEMENTED)) { + return ( + + + The wallet '{err.context.walletName}' ( + {err.context.supportedChains.sort().map((chain, ii, { length }) => ( + + {chain} + {ii === length - 1 ? null : ', '} + + ))} + ) does not support the {err.context.featureName} feature. + + + Features supported: +
    + {err.context.supportedFeatures.sort().map(featureName => ( +
  • + {featureName} +
  • + ))} +
+
+
+ ); + } else if (isWalletStandardError(err, WALLET_STANDARD_ERROR__FEATURES__WALLET_ACCOUNT_CHAIN_UNSUPPORTED)) { + return ( + + + This account does not support the chain {err.context.chain}. + + + Chains supported: +
    + {err.context.supportedChains.sort().map(chain => ( +
  • + {chain} +
  • + ))} +
+
+
+ ); + } else if (err && typeof err === 'object' && 'message' in err) { + return String(err.message); + } + return fallbackMessage; +} diff --git a/examples/react-app/src/functions/balance.ts b/examples/react-app/src/functions/balance.ts new file mode 100644 index 000000000000..cc7ffe6c44c5 --- /dev/null +++ b/examples/react-app/src/functions/balance.ts @@ -0,0 +1,80 @@ +import { + AccountNotificationsApi, + Address, + GetBalanceApi, + LamportsUnsafeBeyond2Pow53Minus1, + Rpc, + RpcSubscriptions, +} from '@solana/web3.js'; +import { SWRSubscription } from 'swr/subscription'; + +const EXPLICIT_ABORT_TOKEN = Symbol(); + +/** + * This is an example of a strategy to fetch some account data and to keep it up to date over time. + * It's implemented as an SWR subscription function (https://swr.vercel.app/docs/subscription) but + * the approach is generalizable. + * + * 1. Fetch the current account state and publish it to the consumer + * 2. Subscribe to account data notifications and publish them to the consumer + * + * At all points in time, check that the update you received -- no matter from where -- is from a + * higher slot (ie. is newer) than the last one you published to the consumer. + */ +export function balanceSubscribe( + rpc: Rpc, + rpcSubscriptions: RpcSubscriptions, + ...subscriptionArgs: Parameters> +) { + const [{ address }, { next }] = subscriptionArgs; + const abortController = new AbortController(); + // Keep track of the slot of the last-published update. + let lastUpdateSlot = -1n; + // Fetch the current balance of this account. + rpc.getBalance(address, { commitment: 'confirmed' }) + .send({ abortSignal: abortController.signal }) + .then(({ context: { slot }, value: lamports }) => { + if (slot < lastUpdateSlot) { + // The last-published update (ie. from the subscription) is newer than this one. + return; + } + lastUpdateSlot = slot; + next(null /* err */, lamports /* data */); + }) + .catch(e => { + if (e !== EXPLICIT_ABORT_TOKEN) { + next(e /* err */); + } + }); + // Subscribe for updates to that balance. + rpcSubscriptions + .accountNotifications(address) + .subscribe({ abortSignal: abortController.signal }) + .then(async accountInfoNotifications => { + try { + for await (const { + context: { slot }, + value: { lamports }, + } of accountInfoNotifications) { + if (slot < lastUpdateSlot) { + // The last-published update (ie. from the initial fetch) is newer than this + // one. + continue; + } + lastUpdateSlot = slot; + next(null /* err */, lamports /* data */); + } + } catch (e) { + next(e /* err */); + } + }) + .catch(e => { + if (e !== EXPLICIT_ABORT_TOKEN) { + next(e /* err */); + } + }); + // Return a cleanup callback that aborts the RPC call/subscription. + return () => { + abortController.abort(EXPLICIT_ABORT_TOKEN); + }; +} diff --git a/examples/react-app/src/hooks/useStable.ts b/examples/react-app/src/hooks/useStable.ts new file mode 100644 index 000000000000..9b905e0f57a5 --- /dev/null +++ b/examples/react-app/src/hooks/useStable.ts @@ -0,0 +1,11 @@ +import { useRef } from 'react'; + +const UNRESOLVED = Symbol(); + +export function useStable(getValue: () => T): T { + const ref = useRef(UNRESOLVED); + if (ref.current === UNRESOLVED) { + ref.current = getValue(); + } + return ref.current; +} diff --git a/examples/react-app/src/index.css b/examples/react-app/src/index.css new file mode 100644 index 000000000000..7d94e52fafeb --- /dev/null +++ b/examples/react-app/src/index.css @@ -0,0 +1 @@ +@import './reset.css'; diff --git a/examples/react-app/src/main.tsx b/examples/react-app/src/main.tsx new file mode 100644 index 000000000000..f160bad144f7 --- /dev/null +++ b/examples/react-app/src/main.tsx @@ -0,0 +1,33 @@ +import './index.css'; +import '@radix-ui/themes/styles.css'; + +import { Flex, Section, Theme } from '@radix-ui/themes'; +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { Nav } from './components/Nav.tsx'; +import { ChainContextProvider } from './context/ChainContext.tsx'; +import { RpcContextProvider } from './context/RpcContext.tsx'; +import { SelectedWalletAccountContextProvider } from './context/SelectedWalletAccountContext.tsx'; +import Root from './routes/root.tsx'; + +const rootNode = document.getElementById('root')!; +const root = createRoot(rootNode); +root.render( + + + + + + +