From 66966f47e7a1ddd8e845aa07044c516959ddafe4 Mon Sep 17 00:00:00 2001 From: Callum Date: Wed, 26 Nov 2025 17:51:07 +0000 Subject: [PATCH] Add to react-app example of signing with wallet and then server signer --- .../src/components/AirdropButton.tsx | 103 ++++++ ...lanaPartialSignTransactionFeaturePanel.tsx | 315 ++++++++++++++++++ examples/react-app/src/routes/root.tsx | 9 + examples/react-app/src/signerBytes.json | 5 + 4 files changed, 432 insertions(+) create mode 100644 examples/react-app/src/components/AirdropButton.tsx create mode 100644 examples/react-app/src/components/SolanaPartialSignTransactionFeaturePanel.tsx create mode 100644 examples/react-app/src/signerBytes.json diff --git a/examples/react-app/src/components/AirdropButton.tsx b/examples/react-app/src/components/AirdropButton.tsx new file mode 100644 index 000000000..d2818fd40 --- /dev/null +++ b/examples/react-app/src/components/AirdropButton.tsx @@ -0,0 +1,103 @@ +import { Blockquote, Button, Dialog, Flex, Link, Text } from '@radix-ui/themes'; +import { Address, airdropFactory, lamports, Rpc, Signature, SolanaRpcApi } from '@solana/kit'; +import { useCallback, useContext, useMemo, useRef, useState } from 'react'; + +import { ChainContext } from '../context/ChainContext'; +import { RpcContext } from '../context/RpcContext'; +import { ErrorDialog } from './ErrorDialog'; + +export function AirdropButton({ address }: { address: Address }) { + const { current: NO_ERROR } = useRef(Symbol()); + const { chain } = useContext(ChainContext); + const { rpc, rpcSubscriptions } = useContext(RpcContext); + const [error, setError] = useState(NO_ERROR); + const [lastSignature, setLastSignature] = useState(); + + const isMainnet = chain === 'solana:mainnet'; + + // Cast RPC for airdrop, this is safe because we disable the airdrop button on mainnet + const airdrop = useMemo( + () => airdropFactory({ rpc: rpc as Rpc, rpcSubscriptions }), + [rpc, rpcSubscriptions], + ); + const [loading, setLoading] = useState(false); + + const handleAirdrop = useCallback(async () => { + try { + if (isMainnet) throw new Error('Airdrops are not available on mainnet'); + setLoading(true); + const signature = await airdrop({ + commitment: 'confirmed', + lamports: lamports(1_000_000_000n), + recipientAddress: address, + }); + setLastSignature(signature); + setError(NO_ERROR); + } catch (e) { + setError(e); + } finally { + setLoading(false); + } + }, [airdrop, address, setLoading, NO_ERROR, isMainnet]); + + return ( + <> + { + if (!open) { + setLastSignature(undefined); + } + }} + > + + + + {lastSignature ? ( + { + e.stopPropagation(); + }} + > + Airdrop successful! + + Signature: +
{lastSignature}
+ + + View this transaction + {' '} + on Explorer + +
+ + + + + +
+ ) : null} +
+ + {error !== NO_ERROR ? ( + setError(NO_ERROR)} + title="Airdrop failed" + /> + ) : null} + + ); +} diff --git a/examples/react-app/src/components/SolanaPartialSignTransactionFeaturePanel.tsx b/examples/react-app/src/components/SolanaPartialSignTransactionFeaturePanel.tsx new file mode 100644 index 000000000..9316d9146 --- /dev/null +++ b/examples/react-app/src/components/SolanaPartialSignTransactionFeaturePanel.tsx @@ -0,0 +1,315 @@ +import { Blockquote, Box, Button, Dialog, Flex, Link, Select, Text, TextField } from '@radix-ui/themes'; +import { + Address, + address, + appendTransactionMessageInstruction, + assertIsSendableTransaction, + assertIsTransactionWithBlockhashLifetime, + createKeyPairFromBytes, + createTransactionMessage, + getBase58Encoder, + getSignatureFromTransaction, + getTransactionDecoder, + getTransactionEncoder, + lamports, + pipe, + SendableTransaction, + sendAndConfirmTransactionFactory, + setTransactionMessageFeePayerSigner, + setTransactionMessageLifetimeUsingBlockhash, + Signature, + SignatureBytes, + signTransaction, + signTransactionMessageWithSigners, + Transaction, + TransactionPartialSigner, + TransactionWithBlockhashLifetime, +} from '@solana/kit'; +import { useWalletAccountTransactionSigner } from '@solana/react'; +import { getTransferSolInstruction } from '@solana-program/system'; +import { ReadonlyUint8Array } from '@wallet-standard/core'; +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 signerBytes from '../signerBytes.json' with { type: 'json' }; +import { AirdropButton } from './AirdropButton'; +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 formatter = new Intl.NumberFormat('en-US', { useGrouping: false }); + const bigIntLamports = BigInt( + // @ts-expect-error - scientific notation is supported by `Intl.NumberFormat` but the types are wrong + formatter.format(`${solQuantityString}E9`).split('.')[0], + ); + return lamports(bigIntLamports); +} + +type SignTransactionState = + | { + kind: 'creating-transaction'; + } + | { + kind: 'inputs-form-active'; + } + | { + kind: 'ready-to-send'; + recipientAddress: Address; + transaction: SendableTransaction & Transaction & TransactionWithBlockhashLifetime; + } + | { + kind: 'sending-transaction'; + }; + +async function mockApiRequest(serializedTransaction: ReadonlyUint8Array): Promise { + const keypair = await createKeyPairFromBytes(new Uint8Array(signerBytes)); + const transaction = getTransactionDecoder().decode(serializedTransaction); + const signedTransaction = await signTransaction([keypair], transaction); + return getBase58Encoder().encode(getSignatureFromTransaction(signedTransaction)) as SignatureBytes; +} + +export function SolanaPartialSignTransactionFeaturePanel({ account }: Props) { + const { mutate } = useSWRConfig(); + const { current: NO_ERROR } = useRef(Symbol()); + const { rpc, rpcSubscriptions } = useContext(RpcContext); + const sendAndConfirmTransaction = useMemo( + () => sendAndConfirmTransactionFactory({ rpc, rpcSubscriptions }), + [rpc, rpcSubscriptions], + ); + const wallets = useWallets(); + 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 transactionSigner = useWalletAccountTransactionSigner(account, currentChain); + const lamportsInputId = useId(); + const recipientSelectId = useId(); + const [signTransactionState, setSignTransactionState] = useState({ + kind: 'inputs-form-active', + }); + const formDisabled = signTransactionState.kind !== 'inputs-form-active'; + const formLoading = + signTransactionState.kind === 'creating-transaction' || signTransactionState.kind === 'sending-transaction'; + + const feePayerAddress = address('HWJowarVUwY7ewUMeFCBqwkin9RPmkmfsVPYrUszNHDV'); + const transactionEncoder = getTransactionEncoder(); + const feePayerSigner: TransactionPartialSigner = { + address: feePayerAddress, + async signTransactions(transactions) { + return await Promise.all( + transactions.map(async transaction => { + const serializedTransaction = transactionEncoder.encode(transaction); + const signatureBytes = await mockApiRequest(serializedTransaction); + return { [feePayerAddress]: signatureBytes }; + }), + ); + }, + }; + + async function handleCreateTransaction(event: React.FormEvent) { + event.preventDefault(); + setError(NO_ERROR); + setSignTransactionState({ kind: 'creating-transaction' }); + 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(feePayerSigner, m), + m => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, m), + m => + appendTransactionMessageInstruction( + getTransferSolInstruction({ + amount, + destination: address(recipientAccount.address), + source: transactionSigner, + }), + m, + ), + ); + const transaction = await signTransactionMessageWithSigners(message); + assertIsSendableTransaction(transaction); + assertIsTransactionWithBlockhashLifetime(transaction); + setSignTransactionState({ + kind: 'ready-to-send', + recipientAddress: recipientAccount.address as Address, + transaction, + }); + } catch (e) { + setLastSignature(undefined); + setError(e); + setSignTransactionState({ kind: 'inputs-form-active' }); + } + } + + async function handleSendTransaction( + { + recipientAddress, + transaction, + }: { + recipientAddress: Address; + transaction: SendableTransaction & Transaction & TransactionWithBlockhashLifetime; + }, + event: React.FormEvent, + ) { + event.preventDefault(); + setError(NO_ERROR); + setSignTransactionState({ kind: 'sending-transaction' }); + try { + const signature = getSignatureFromTransaction(transaction); + await sendAndConfirmTransaction(transaction, { commitment: 'confirmed' }); + void mutate({ address: transactionSigner.address, chain: currentChain }); + void mutate({ address: recipientAddress, chain: currentChain }); + setLastSignature(signature); + setSolQuantityString(''); + setSignTransactionState({ kind: 'inputs-form-active' }); + } catch (e) { + setLastSignature(undefined); + setError(e); + setSignTransactionState({ kind: 'inputs-form-active' }); + } + } + + return ( + +
+ + + + ) => + 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: +
{lastSignature}
+ + + View this transaction + {' '} + on Explorer + +
+ + + + + +
+ ) : null} +
+ {error !== NO_ERROR ? ( + setError(NO_ERROR)} title="Transfer failed" /> + ) : null} + +
+ ); +} diff --git a/examples/react-app/src/routes/root.tsx b/examples/react-app/src/routes/root.tsx index ebb0fc5e5..65d153d20 100644 --- a/examples/react-app/src/routes/root.tsx +++ b/examples/react-app/src/routes/root.tsx @@ -6,6 +6,7 @@ import { ErrorBoundary } from 'react-error-boundary'; import { Balance } from '../components/Balance'; import { FeatureNotSupportedCallout } from '../components/FeatureNotSupportedCallout'; import { FeaturePanel } from '../components/FeaturePanel'; +import { SolanaPartialSignTransactionFeaturePanel } from '../components/SolanaPartialSignTransactionFeaturePanel'; import { SolanaSignAndSendTransactionFeaturePanel } from '../components/SolanaSignAndSendTransactionFeaturePanel'; import { SolanaSignMessageFeaturePanel } from '../components/SolanaSignMessageFeaturePanel'; import { SolanaSignTransactionFeaturePanel } from '../components/SolanaSignTransactionFeaturePanel'; @@ -75,6 +76,14 @@ function Root() { + + + + + ) : ( diff --git a/examples/react-app/src/signerBytes.json b/examples/react-app/src/signerBytes.json new file mode 100644 index 000000000..863ec09ff --- /dev/null +++ b/examples/react-app/src/signerBytes.json @@ -0,0 +1,5 @@ +[ + 220, 120, 88, 208, 87, 244, 238, 165, 90, 164, 136, 113, 27, 148, 169, 29, 21, 60, 86, 177, 76, 127, 211, 167, 247, + 197, 252, 79, 54, 129, 133, 164, 245, 60, 248, 170, 228, 164, 94, 72, 61, 61, 38, 217, 24, 41, 169, 145, 19, 101, + 146, 47, 59, 199, 9, 139, 92, 13, 33, 135, 34, 249, 98, 108 +]