diff --git a/.changeset/add-wsol-wrap-unwrap-helpers.md b/.changeset/add-wsol-wrap-unwrap-helpers.md
new file mode 100644
index 0000000..10571c3
--- /dev/null
+++ b/.changeset/add-wsol-wrap-unwrap-helpers.md
@@ -0,0 +1,33 @@
+---
+"@solana/client": minor
+"@solana/react-hooks": minor
+---
+
+Add wrapSol/unwrapSol helpers for wSOL operations
+
+Adds helper functions to easily wrap native SOL into Wrapped SOL (wSOL) and unwrap it back:
+
+**@solana/client:**
+- `createWsolHelper(runtime)` - Factory function to create wSOL helpers
+- `WsolHelper.sendWrap({ amount, authority })` - Wrap SOL to wSOL
+- `WsolHelper.sendUnwrap({ authority })` - Unwrap wSOL back to SOL (closes the account)
+- `WsolHelper.fetchWsolBalance(owner)` - Get wSOL balance
+- `WsolHelper.deriveWsolAddress(owner)` - Derive the wSOL ATA address
+- `WRAPPED_SOL_MINT` - The wSOL mint address constant
+- `createWsolController()` - Controller for React integration
+
+**@solana/react-hooks:**
+- `useWrapSol()` - Hook for wrapping/unwrapping SOL with status tracking
+
+Example usage:
+```ts
+// Using the client helper
+const wsol = client.wsol;
+await wsol.sendWrap({ amount: 1_000_000_000n, authority: session });
+await wsol.sendUnwrap({ authority: session });
+
+// Using the React hook
+const { wrap, unwrap, balance, isWrapping, isUnwrapping } = useWrapSol();
+await wrap({ amount: 1_000_000_000n });
+await unwrap({});
+```
diff --git a/examples/vite-react/src/App.tsx b/examples/vite-react/src/App.tsx
index b8ce4f8..92d41fc 100644
--- a/examples/vite-react/src/App.tsx
+++ b/examples/vite-react/src/App.tsx
@@ -18,6 +18,7 @@ import { StoreInspectorCard } from './components/StoreInspectorCard.tsx';
import { TransactionPoolPanel } from './components/TransactionPoolPanel.tsx';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui/tabs.tsx';
import { WalletControls } from './components/WalletControls.tsx';
+import { WrapSolPanel } from './components/WrapSolPanel.tsx';
const walletConnectors = [...phantom(), ...solflare(), ...backpack(), ...metamask(), ...autoDiscover()];
const client = createClient({
@@ -82,6 +83,7 @@ function DemoApp() {
+
diff --git a/examples/vite-react/src/components/WrapSolPanel.tsx b/examples/vite-react/src/components/WrapSolPanel.tsx
new file mode 100644
index 0000000..1e0eeba
--- /dev/null
+++ b/examples/vite-react/src/components/WrapSolPanel.tsx
@@ -0,0 +1,223 @@
+import { WRAPPED_SOL_MINT } from '@solana/client';
+import { useWalletSession, useWrapSol } from '@solana/react-hooks';
+import { type FormEvent, useState } from 'react';
+
+import { Button } from './ui/button';
+import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from './ui/card';
+import { Input } from './ui/input';
+
+function formatError(error: unknown): string {
+ if (error instanceof Error) {
+ return error.message;
+ }
+ if (typeof error === 'string') {
+ return error;
+ }
+ return JSON.stringify(error);
+}
+
+function formatWsolBalance(balance: { amount: bigint; exists: boolean } | null, owner: string | null): string {
+ if (!owner) {
+ return 'Connect a wallet to see your wSOL balance.';
+ }
+ if (!balance) {
+ return 'Loading balance...';
+ }
+ if (!balance.exists) {
+ return '0 wSOL (no token account)';
+ }
+ const solAmount = Number(balance.amount) / 1_000_000_000;
+ return `${solAmount.toFixed(9)} wSOL (${balance.amount.toString()} lamports)`;
+}
+
+export function WrapSolPanel() {
+ const session = useWalletSession();
+ const [wrapAmount, setWrapAmount] = useState('0.1');
+ const {
+ balance,
+ error,
+ isFetching,
+ isUnwrapping,
+ isWrapping,
+ owner,
+ refresh,
+ refreshing,
+ resetUnwrap,
+ resetWrap,
+ unwrap,
+ unwrapError,
+ unwrapSignature,
+ unwrapStatus,
+ wrap,
+ wrapError,
+ wrapSignature,
+ wrapStatus,
+ status,
+ } = useWrapSol();
+
+ const handleWrap = async (event: FormEvent
) => {
+ event.preventDefault();
+ if (!session) {
+ return;
+ }
+ const amountStr = wrapAmount.trim();
+ if (!amountStr) {
+ return;
+ }
+ const solAmount = parseFloat(amountStr);
+ if (Number.isNaN(solAmount) || solAmount <= 0) {
+ return;
+ }
+ // Convert SOL to lamports
+ const lamports = BigInt(Math.floor(solAmount * 1_000_000_000));
+ await wrap({ amount: lamports });
+ await refresh();
+ };
+
+ const handleUnwrap = async () => {
+ if (!session) {
+ return;
+ }
+ await unwrap({});
+ await refresh();
+ };
+
+ const isWalletConnected = Boolean(owner);
+ const hasWsolBalance = balance?.exists && balance.amount > 0n;
+
+ const getWrapStatus = (): string => {
+ if (!owner) {
+ return 'Connect a wallet to wrap SOL.';
+ }
+ if (isWrapping || wrapStatus === 'loading') {
+ return 'Wrapping SOL...';
+ }
+ if (wrapStatus === 'success' && wrapSignature) {
+ return `Wrap successful! Signature: ${String(wrapSignature)}`;
+ }
+ if (wrapStatus === 'error' && wrapError) {
+ return `Wrap failed: ${formatError(wrapError)}`;
+ }
+ return 'Enter an amount and click Wrap to convert SOL to wSOL.';
+ };
+
+ const getUnwrapStatus = (): string => {
+ if (!owner) {
+ return '';
+ }
+ if (isUnwrapping || unwrapStatus === 'loading') {
+ return 'Unwrapping wSOL...';
+ }
+ if (unwrapStatus === 'success' && unwrapSignature) {
+ return `Unwrap successful! Signature: ${String(unwrapSignature)}`;
+ }
+ if (unwrapStatus === 'error' && unwrapError) {
+ return `Unwrap failed: ${formatError(unwrapError)}`;
+ }
+ if (!hasWsolBalance) {
+ return 'No wSOL to unwrap.';
+ }
+ return 'Click Unwrap to convert all wSOL back to SOL.';
+ };
+
+ return (
+
+
+
+ Wrapped SOL (wSOL)
+
+ Wrap native SOL into wSOL and unwrap it back using the useWrapSol hook.
+
+
+
+
+
+
+ Mint:{' '}
+ {WRAPPED_SOL_MINT}
+
+
+ Owner:{' '}
+ {owner ? {owner} : 'Connect a wallet'}
+
+
+
+ {/* Balance Section */}
+
+
+
+
+
+ {status === 'error' && error
+ ? `Error: ${formatError(error)}`
+ : formatWsolBalance(balance, owner)}
+
+
+
+ {/* Wrap Form */}
+
+
+ {getWrapStatus()}
+
+
+ {/* Unwrap Section */}
+
+
Unwrap wSOL
+
+ Unwrapping closes your wSOL token account and returns all SOL to your wallet.
+
+
+
+
+
+
+
+
+
+ {getUnwrapStatus()}
+
+
+
+ );
+}
diff --git a/packages/client/src/client/createClient.ts b/packages/client/src/client/createClient.ts
index cd73459..b8febc4 100644
--- a/packages/client/src/client/createClient.ts
+++ b/packages/client/src/client/createClient.ts
@@ -100,6 +100,9 @@ export function createClient(config: SolanaClientConfig): SolanaClient {
get transaction() {
return helpers.transaction;
},
+ get wsol() {
+ return helpers.wsol;
+ },
prepareTransaction: helpers.prepareTransaction,
watchers,
};
diff --git a/packages/client/src/client/createClientHelpers.ts b/packages/client/src/client/createClientHelpers.ts
index 40f6969..71a1104 100644
--- a/packages/client/src/client/createClientHelpers.ts
+++ b/packages/client/src/client/createClientHelpers.ts
@@ -4,6 +4,7 @@ import { createSolTransferHelper, type SolTransferHelper } from '../features/sol
import { createSplTokenHelper, type SplTokenHelper, type SplTokenHelperConfig } from '../features/spl';
import { createStakeHelper, type StakeHelper } from '../features/stake';
import { createTransactionHelper, type TransactionHelper } from '../features/transactions';
+import { createWsolHelper, type WsolHelper } from '../features/wsol';
import type { SolanaClientRuntime } from '../rpc/types';
import {
type PrepareTransactionMessage,
@@ -72,6 +73,19 @@ function wrapStakeHelper(helper: StakeHelper, getFallback: () => Commitment): St
};
}
+function wrapWsolHelper(helper: WsolHelper, getFallback: () => Commitment): WsolHelper {
+ return {
+ deriveWsolAddress: helper.deriveWsolAddress,
+ fetchWsolBalance: (owner, commitment) => helper.fetchWsolBalance(owner, commitment ?? getFallback()),
+ prepareWrap: (config) => helper.prepareWrap(withDefaultCommitment(config, getFallback)),
+ prepareUnwrap: (config) => helper.prepareUnwrap(withDefaultCommitment(config, getFallback)),
+ sendPreparedWrap: helper.sendPreparedWrap,
+ sendPreparedUnwrap: helper.sendPreparedUnwrap,
+ sendWrap: (config, options) => helper.sendWrap(withDefaultCommitment(config, getFallback), options),
+ sendUnwrap: (config, options) => helper.sendUnwrap(withDefaultCommitment(config, getFallback), options),
+ };
+}
+
function normaliseConfigValue(value: unknown): string | undefined {
if (value === null || value === undefined) {
return undefined;
@@ -101,6 +115,7 @@ export function createClientHelpers(runtime: SolanaClientRuntime, store: ClientS
let solTransfer: SolTransferHelper | undefined;
let stake: StakeHelper | undefined;
let transaction: TransactionHelper | undefined;
+ let wsol: WsolHelper | undefined;
const getSolTransfer = () => {
if (!solTransfer) {
@@ -123,6 +138,13 @@ export function createClientHelpers(runtime: SolanaClientRuntime, store: ClientS
return transaction;
};
+ const getWsol = () => {
+ if (!wsol) {
+ wsol = wrapWsolHelper(createWsolHelper(runtime), getFallbackCommitment);
+ }
+ return wsol;
+ };
+
function getSplTokenHelper(config: SplTokenHelperConfig): SplTokenHelper {
const cacheKey = serialiseSplConfig(config);
const cached = splTokenCache.get(cacheKey);
@@ -157,6 +179,9 @@ export function createClientHelpers(runtime: SolanaClientRuntime, store: ClientS
get transaction() {
return getTransaction();
},
+ get wsol() {
+ return getWsol();
+ },
prepareTransaction: prepareTransactionWithRuntime,
});
}
diff --git a/packages/client/src/controllers/wsolController.ts b/packages/client/src/controllers/wsolController.ts
new file mode 100644
index 0000000..81c3844
--- /dev/null
+++ b/packages/client/src/controllers/wsolController.ts
@@ -0,0 +1,138 @@
+import type { SolTransferSendOptions } from '../features/sol';
+import type { WsolHelper, WsolUnwrapPrepareConfig, WsolWrapPrepareConfig } from '../features/wsol';
+import { type AsyncState, createAsyncState, createInitialAsyncState } from '../state/asyncState';
+
+type WsolWrapSignature = Awaited>;
+type WsolUnwrapSignature = Awaited>;
+
+type Listener = () => void;
+
+export type WsolControllerConfig = Readonly<{
+ authorityProvider?: () => WsolWrapPrepareConfig['authority'] | undefined;
+ helper: WsolHelper;
+}>;
+
+export type WsolWrapInput = Omit & {
+ authority?: WsolWrapPrepareConfig['authority'];
+};
+
+export type WsolUnwrapInput = Omit & {
+ authority?: WsolUnwrapPrepareConfig['authority'];
+};
+
+export type WsolController = Readonly<{
+ getHelper(): WsolHelper;
+ getWrapState(): AsyncState;
+ getUnwrapState(): AsyncState;
+ resetWrap(): void;
+ resetUnwrap(): void;
+ wrap(config: WsolWrapInput, options?: SolTransferSendOptions): Promise;
+ unwrap(config: WsolUnwrapInput, options?: SolTransferSendOptions): Promise;
+ subscribeWrap(listener: Listener): () => void;
+ subscribeUnwrap(listener: Listener): () => void;
+}>;
+
+function ensureAuthority(
+ input: T,
+ resolveDefault?: () => WsolWrapPrepareConfig['authority'] | undefined,
+): T & { authority: WsolWrapPrepareConfig['authority'] } {
+ const authority = input.authority ?? resolveDefault?.();
+ if (!authority) {
+ throw new Error('Connect a wallet or supply an `authority` before wrapping/unwrapping SOL.');
+ }
+ return {
+ ...input,
+ authority,
+ };
+}
+
+export function createWsolController(config: WsolControllerConfig): WsolController {
+ const wrapListeners = new Set();
+ const unwrapListeners = new Set();
+ const helper = config.helper;
+ const authorityProvider = config.authorityProvider;
+ let wrapState: AsyncState = createInitialAsyncState();
+ let unwrapState: AsyncState = createInitialAsyncState();
+
+ function notifyWrap() {
+ for (const listener of wrapListeners) {
+ listener();
+ }
+ }
+
+ function notifyUnwrap() {
+ for (const listener of unwrapListeners) {
+ listener();
+ }
+ }
+
+ function setWrapState(next: AsyncState) {
+ wrapState = next;
+ notifyWrap();
+ }
+
+ function setUnwrapState(next: AsyncState) {
+ unwrapState = next;
+ notifyUnwrap();
+ }
+
+ async function wrap(input: WsolWrapInput, options?: SolTransferSendOptions): Promise {
+ const request = ensureAuthority(input, authorityProvider);
+ setWrapState(createAsyncState('loading'));
+ try {
+ const signature = await helper.sendWrap(request, options);
+ setWrapState(createAsyncState('success', { data: signature }));
+ return signature;
+ } catch (error) {
+ setWrapState(createAsyncState('error', { error }));
+ throw error;
+ }
+ }
+
+ async function unwrap(input: WsolUnwrapInput, options?: SolTransferSendOptions): Promise {
+ const request = ensureAuthority(input, authorityProvider);
+ setUnwrapState(createAsyncState('loading'));
+ try {
+ const signature = await helper.sendUnwrap(request, options);
+ setUnwrapState(createAsyncState('success', { data: signature }));
+ return signature;
+ } catch (error) {
+ setUnwrapState(createAsyncState('error', { error }));
+ throw error;
+ }
+ }
+
+ function subscribeWrap(listener: Listener): () => void {
+ wrapListeners.add(listener);
+ return () => {
+ wrapListeners.delete(listener);
+ };
+ }
+
+ function subscribeUnwrap(listener: Listener): () => void {
+ unwrapListeners.add(listener);
+ return () => {
+ unwrapListeners.delete(listener);
+ };
+ }
+
+ function resetWrap() {
+ setWrapState(createInitialAsyncState());
+ }
+
+ function resetUnwrap() {
+ setUnwrapState(createInitialAsyncState());
+ }
+
+ return {
+ getHelper: () => helper,
+ getWrapState: () => wrapState,
+ getUnwrapState: () => unwrapState,
+ resetWrap,
+ resetUnwrap,
+ wrap,
+ unwrap,
+ subscribeWrap,
+ subscribeUnwrap,
+ };
+}
diff --git a/packages/client/src/features/wsol.ts b/packages/client/src/features/wsol.ts
new file mode 100644
index 0000000..267a234
--- /dev/null
+++ b/packages/client/src/features/wsol.ts
@@ -0,0 +1,393 @@
+import { getBase58Decoder } from '@solana/codecs-strings';
+import {
+ type Address,
+ address,
+ appendTransactionMessageInstruction,
+ appendTransactionMessageInstructions,
+ type Blockhash,
+ type Commitment,
+ createTransactionMessage,
+ createTransactionPlanExecutor,
+ getBase64EncodedWireTransaction,
+ isSolanaError,
+ isTransactionSendingSigner,
+ pipe,
+ SOLANA_ERROR__TRANSACTION_ERROR__ALREADY_PROCESSED,
+ setTransactionMessageFeePayer,
+ setTransactionMessageLifetimeUsingBlockhash,
+ signAndSendTransactionMessageWithSigners,
+ signature,
+ signTransactionMessageWithSigners,
+ singleTransactionPlan,
+ type TransactionPlan,
+ type TransactionSigner,
+ type TransactionVersion,
+} from '@solana/kit';
+import { getTransferSolInstruction } from '@solana-program/system';
+import {
+ findAssociatedTokenPda,
+ getCloseAccountInstruction,
+ getCreateAssociatedTokenIdempotentInstruction,
+ getSyncNativeInstruction,
+ TOKEN_PROGRAM_ADDRESS,
+} from '@solana-program/token';
+
+import { lamportsMath } from '../numeric/lamports';
+import type { SolanaClientRuntime } from '../rpc/types';
+import { createWalletTransactionSigner, isWalletSession, resolveSignerMode } from '../signers/walletTransactionSigner';
+import type { WalletSession } from '../wallet/types';
+import type { SolTransferSendOptions } from './sol';
+
+/** Wrapped SOL mint address (same on all clusters). */
+export const WRAPPED_SOL_MINT = address('So11111111111111111111111111111111111111112');
+
+type BlockhashLifetime = Readonly<{
+ blockhash: Blockhash;
+ lastValidBlockHeight: bigint;
+}>;
+
+type WsolAuthority = TransactionSigner | WalletSession;
+
+type SignableWsolTransactionMessage = Parameters[0];
+
+export type WsolWrapPrepareConfig = Readonly<{
+ /** Amount of SOL to wrap (in lamports, SOL string, or number). */
+ amount: bigint | number | string;
+ /** Authority that signs the transaction (wallet session or raw signer). */
+ authority: WsolAuthority;
+ /** Commitment level for the transaction. */
+ commitment?: Commitment;
+ /** Optional existing blockhash lifetime to reuse. */
+ lifetime?: BlockhashLifetime;
+ /** Owner of the wSOL account. Defaults to authority address. */
+ owner?: Address | string;
+ /** Transaction version (defaults to 0). */
+ transactionVersion?: TransactionVersion;
+}>;
+
+export type WsolUnwrapPrepareConfig = Readonly<{
+ /** Authority that signs the transaction (wallet session or raw signer). */
+ authority: WsolAuthority;
+ /** Commitment level for the transaction. */
+ commitment?: Commitment;
+ /** Optional existing blockhash lifetime to reuse. */
+ lifetime?: BlockhashLifetime;
+ /** Owner of the wSOL account. Defaults to authority address. */
+ owner?: Address | string;
+ /** Transaction version (defaults to 0). */
+ transactionVersion?: TransactionVersion;
+}>;
+
+type PreparedWsolWrap = Readonly<{
+ amount: bigint;
+ ataAddress: Address;
+ commitment?: Commitment;
+ lifetime: BlockhashLifetime;
+ message: SignableWsolTransactionMessage;
+ mode: 'partial' | 'send';
+ owner: Address;
+ plan?: TransactionPlan;
+ signer: TransactionSigner;
+}>;
+
+type PreparedWsolUnwrap = Readonly<{
+ ataAddress: Address;
+ commitment?: Commitment;
+ lifetime: BlockhashLifetime;
+ message: SignableWsolTransactionMessage;
+ mode: 'partial' | 'send';
+ owner: Address;
+ plan?: TransactionPlan;
+ signer: TransactionSigner;
+}>;
+
+function ensureAddress(value: Address | string | undefined, fallback?: Address): Address {
+ if (value) {
+ return typeof value === 'string' ? address(value) : value;
+ }
+ if (!fallback) {
+ throw new Error('An address value was expected but not provided.');
+ }
+ return fallback;
+}
+
+async function resolveLifetime(
+ runtime: SolanaClientRuntime,
+ commitment?: Commitment,
+ fallback?: BlockhashLifetime,
+): Promise {
+ if (fallback) {
+ return fallback;
+ }
+ const { value } = await runtime.rpc.getLatestBlockhash({ commitment }).send();
+ return value;
+}
+
+function resolveSigner(
+ authority: WsolAuthority,
+ commitment?: Commitment,
+): { mode: 'partial' | 'send'; signer: TransactionSigner } {
+ if (isWalletSession(authority)) {
+ const { signer, mode } = createWalletTransactionSigner(authority, { commitment });
+ return { mode, signer };
+ }
+ return { mode: resolveSignerMode(authority), signer: authority };
+}
+
+function toLamportAmount(input: bigint | number | string): bigint {
+ return lamportsMath.fromLamports(input);
+}
+
+export type WsolHelper = Readonly<{
+ /** Derive the wSOL Associated Token Address for an owner. */
+ deriveWsolAddress(owner: Address | string): Promise;
+ /** Fetch the wSOL balance for an owner. */
+ fetchWsolBalance(owner: Address | string, commitment?: Commitment): Promise;
+ /** Prepare a wrap transaction without sending. */
+ prepareWrap(config: WsolWrapPrepareConfig): Promise;
+ /** Prepare an unwrap transaction without sending. */
+ prepareUnwrap(config: WsolUnwrapPrepareConfig): Promise;
+ /** Send a previously prepared wrap transaction. */
+ sendPreparedWrap(
+ prepared: PreparedWsolWrap,
+ options?: SolTransferSendOptions,
+ ): Promise>;
+ /** Send a previously prepared unwrap transaction. */
+ sendPreparedUnwrap(
+ prepared: PreparedWsolUnwrap,
+ options?: SolTransferSendOptions,
+ ): Promise>;
+ /** Wrap SOL to wSOL in one call. */
+ sendWrap(config: WsolWrapPrepareConfig, options?: SolTransferSendOptions): Promise>;
+ /** Unwrap wSOL to SOL in one call (closes the wSOL account). */
+ sendUnwrap(
+ config: WsolUnwrapPrepareConfig,
+ options?: SolTransferSendOptions,
+ ): Promise>;
+}>;
+
+export type WsolBalance = Readonly<{
+ amount: bigint;
+ ataAddress: Address;
+ exists: boolean;
+}>;
+
+/** Creates helpers for wrapping native SOL to wSOL and unwrapping back. */
+export function createWsolHelper(runtime: SolanaClientRuntime): WsolHelper {
+ const tokenProgram = address(TOKEN_PROGRAM_ADDRESS);
+
+ async function deriveWsolAddress(owner: Address | string): Promise {
+ const [ata] = await findAssociatedTokenPda({
+ mint: WRAPPED_SOL_MINT,
+ owner: ensureAddress(owner),
+ tokenProgram,
+ });
+ return ata;
+ }
+
+ async function fetchWsolBalance(owner: Address | string, commitment?: Commitment): Promise {
+ const ataAddress = await deriveWsolAddress(owner);
+ try {
+ const { value } = await runtime.rpc.getTokenAccountBalance(ataAddress, { commitment }).send();
+ const amount = BigInt(value.amount);
+ return {
+ amount,
+ ataAddress,
+ exists: true,
+ };
+ } catch {
+ return {
+ amount: 0n,
+ ataAddress,
+ exists: false,
+ };
+ }
+ }
+
+ async function prepareWrap(config: WsolWrapPrepareConfig): Promise {
+ const commitment = config.commitment;
+ const lifetime = await resolveLifetime(runtime, commitment, config.lifetime);
+ const { signer, mode } = resolveSigner(config.authority, commitment);
+ const owner = ensureAddress(config.owner, signer.address);
+ const amount = toLamportAmount(config.amount);
+ const ataAddress = await deriveWsolAddress(owner);
+
+ // Instructions:
+ // 1. Create ATA if it doesn't exist (idempotent)
+ // 2. Transfer SOL to ATA
+ // 3. Sync native to update token balance
+ const instructions = [
+ getCreateAssociatedTokenIdempotentInstruction({
+ ata: ataAddress,
+ mint: WRAPPED_SOL_MINT,
+ owner,
+ payer: signer,
+ tokenProgram,
+ }),
+ getTransferSolInstruction({
+ amount,
+ destination: ataAddress,
+ source: signer,
+ }),
+ getSyncNativeInstruction({
+ account: ataAddress,
+ }),
+ ];
+
+ const message = pipe(
+ createTransactionMessage({ version: config.transactionVersion ?? 0 }),
+ (m) => setTransactionMessageFeePayer(signer.address, m),
+ (m) => setTransactionMessageLifetimeUsingBlockhash(lifetime, m),
+ (m) => appendTransactionMessageInstructions(instructions, m),
+ );
+
+ return {
+ amount,
+ ataAddress,
+ commitment,
+ lifetime,
+ message,
+ mode,
+ owner,
+ plan: singleTransactionPlan(message),
+ signer,
+ };
+ }
+
+ async function prepareUnwrap(config: WsolUnwrapPrepareConfig): Promise {
+ const commitment = config.commitment;
+ const lifetime = await resolveLifetime(runtime, commitment, config.lifetime);
+ const { signer, mode } = resolveSigner(config.authority, commitment);
+ const owner = ensureAddress(config.owner, signer.address);
+ const ataAddress = await deriveWsolAddress(owner);
+
+ // Close account instruction transfers remaining lamports to destination
+ const instruction = getCloseAccountInstruction({
+ account: ataAddress,
+ destination: owner,
+ owner: signer,
+ });
+
+ const message = pipe(
+ createTransactionMessage({ version: config.transactionVersion ?? 0 }),
+ (m) => setTransactionMessageFeePayer(signer.address, m),
+ (m) => setTransactionMessageLifetimeUsingBlockhash(lifetime, m),
+ (m) => appendTransactionMessageInstruction(instruction, m),
+ );
+
+ return {
+ ataAddress,
+ commitment,
+ lifetime,
+ message,
+ mode,
+ owner,
+ plan: singleTransactionPlan(message),
+ signer,
+ };
+ }
+
+ async function sendPreparedTransaction(
+ prepared: PreparedWsolWrap | PreparedWsolUnwrap,
+ options: SolTransferSendOptions = {},
+ ): Promise> {
+ if (prepared.mode === 'send' && isTransactionSendingSigner(prepared.signer)) {
+ const signatureBytes = await signAndSendTransactionMessageWithSigners(prepared.message, {
+ abortSignal: options.abortSignal,
+ minContextSlot: options.minContextSlot,
+ });
+ const base58Decoder = getBase58Decoder();
+ return signature(base58Decoder.decode(signatureBytes));
+ }
+
+ const commitment = options.commitment ?? prepared.commitment;
+ const maxRetries =
+ options.maxRetries === undefined
+ ? undefined
+ : typeof options.maxRetries === 'bigint'
+ ? options.maxRetries
+ : BigInt(options.maxRetries);
+ let latestSignature: ReturnType | null = null;
+ const executor = createTransactionPlanExecutor({
+ async executeTransactionMessage(message, config = {}) {
+ const signed = await signTransactionMessageWithSigners(message as SignableWsolTransactionMessage, {
+ abortSignal: config.abortSignal ?? options.abortSignal,
+ minContextSlot: options.minContextSlot,
+ });
+ const wire = getBase64EncodedWireTransaction(signed);
+ const response = await runtime.rpc
+ .sendTransaction(wire, {
+ encoding: 'base64',
+ maxRetries,
+ preflightCommitment: commitment,
+ skipPreflight: options.skipPreflight,
+ })
+ .send({ abortSignal: config.abortSignal ?? options.abortSignal });
+ latestSignature = signature(response);
+ return { transaction: signed };
+ },
+ });
+ await executor(prepared.plan ?? singleTransactionPlan(prepared.message), { abortSignal: options.abortSignal });
+ if (!latestSignature) {
+ throw new Error('Failed to resolve transaction signature.');
+ }
+ return latestSignature;
+ }
+
+ async function sendPreparedWrap(
+ prepared: PreparedWsolWrap,
+ options?: SolTransferSendOptions,
+ ): Promise> {
+ return sendPreparedTransaction(prepared, options);
+ }
+
+ async function sendPreparedUnwrap(
+ prepared: PreparedWsolUnwrap,
+ options?: SolTransferSendOptions,
+ ): Promise> {
+ return sendPreparedTransaction(prepared, options);
+ }
+
+ async function sendWrap(
+ config: WsolWrapPrepareConfig,
+ options?: SolTransferSendOptions,
+ ): Promise> {
+ const prepared = await prepareWrap(config);
+ try {
+ return await sendPreparedWrap(prepared, options);
+ } catch (error) {
+ if (isSolanaError(error, SOLANA_ERROR__TRANSACTION_ERROR__ALREADY_PROCESSED)) {
+ const retriedPrepared = await prepareWrap({ ...config, lifetime: undefined });
+ return await sendPreparedWrap(retriedPrepared, options);
+ }
+ throw error;
+ }
+ }
+
+ async function sendUnwrap(
+ config: WsolUnwrapPrepareConfig,
+ options?: SolTransferSendOptions,
+ ): Promise> {
+ const prepared = await prepareUnwrap(config);
+ try {
+ return await sendPreparedUnwrap(prepared, options);
+ } catch (error) {
+ if (isSolanaError(error, SOLANA_ERROR__TRANSACTION_ERROR__ALREADY_PROCESSED)) {
+ const retriedPrepared = await prepareUnwrap({ ...config, lifetime: undefined });
+ return await sendPreparedUnwrap(retriedPrepared, options);
+ }
+ throw error;
+ }
+ }
+
+ return {
+ deriveWsolAddress,
+ fetchWsolBalance,
+ prepareUnwrap,
+ prepareWrap,
+ sendPreparedUnwrap,
+ sendPreparedWrap,
+ sendUnwrap,
+ sendWrap,
+ };
+}
diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts
index 0294a55..a277ad2 100644
--- a/packages/client/src/index.ts
+++ b/packages/client/src/index.ts
@@ -38,6 +38,13 @@ export {
type UnstakeInput,
type WithdrawInput,
} from './controllers/stakeController';
+export {
+ createWsolController,
+ type WsolController,
+ type WsolControllerConfig,
+ type WsolUnwrapInput,
+ type WsolWrapInput,
+} from './controllers/wsolController';
export {
createSolTransferHelper,
type SolTransferHelper,
@@ -75,6 +82,14 @@ export {
type TransactionSendOptions,
type TransactionSignOptions,
} from './features/transactions';
+export {
+ createWsolHelper,
+ WRAPPED_SOL_MINT,
+ type WsolBalance,
+ type WsolHelper,
+ type WsolUnwrapPrepareConfig,
+ type WsolWrapPrepareConfig,
+} from './features/wsol';
export {
createTokenAmount,
type FormatAmountOptions,
diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts
index b36626c..be924e0 100644
--- a/packages/client/src/types.ts
+++ b/packages/client/src/types.ts
@@ -14,6 +14,7 @@ import type { SolTransferHelper } from './features/sol';
import type { SplTokenHelper, SplTokenHelperConfig } from './features/spl';
import type { StakeHelper } from './features/stake';
import type { TransactionHelper } from './features/transactions';
+import type { WsolHelper } from './features/wsol';
import type { SolanaRpcClient } from './rpc/createSolanaRpcClient';
import type { SolanaClientRuntime } from './rpc/types';
import type { PrepareTransactionMessage, PrepareTransactionOptions } from './transactions/prepareTransaction';
@@ -277,6 +278,7 @@ export type ClientHelpers = Readonly<{
splToken(config: SplTokenHelperConfig): SplTokenHelper;
stake: StakeHelper;
transaction: TransactionHelper;
+ wsol: WsolHelper;
prepareTransaction(
config: PrepareTransactionOptions,
): Promise;
@@ -298,5 +300,6 @@ export type SolanaClient = Readonly<{
SplHelper(config: SplTokenHelperConfig): SplTokenHelper;
stake: StakeHelper;
transaction: TransactionHelper;
+ wsol: WsolHelper;
prepareTransaction: ClientHelpers['prepareTransaction'];
}>;
diff --git a/packages/react-hooks/src/hooks.ts b/packages/react-hooks/src/hooks.ts
index f302611..3b31315 100644
--- a/packages/react-hooks/src/hooks.ts
+++ b/packages/react-hooks/src/hooks.ts
@@ -12,6 +12,7 @@ import {
createSplTransferController,
createStakeController,
createTransactionPoolController,
+ createWsolController,
deriveConfirmationStatus,
type LatestBlockhashCache,
type NonceAccountData,
@@ -49,6 +50,11 @@ import {
type WalletStatus,
type WithdrawInput,
type WithdrawSendOptions,
+ type WsolBalance,
+ type WsolController,
+ type WsolHelper,
+ type WsolUnwrapInput,
+ type WsolWrapInput,
} from '@solana/client';
import type { Commitment, Lamports, Signature } from '@solana/kit';
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
@@ -998,6 +1004,175 @@ export function useSendTransaction(): UseSendTransactionResult {
};
}
+type WsolWrapSignature = Awaited>;
+type WsolUnwrapSignature = Awaited>;
+
+type UseWrapSolOptions = Readonly<{
+ commitment?: Commitment;
+ owner?: AddressLike;
+ revalidateOnFocus?: boolean;
+ swr?: Omit>, 'fallback' | 'suspense'>;
+}>;
+
+/**
+ * Convenience hook for wrapping and unwrapping SOL to/from wSOL.
+ *
+ * @example
+ * ```ts
+ * const { balance, wrap, unwrap, isWrapping, isUnwrapping } = useWrapSol();
+ * // Wrap 1 SOL to wSOL
+ * await wrap({ amount: 1_000_000_000n });
+ * // Unwrap all wSOL back to SOL
+ * await unwrap({});
+ * ```
+ */
+export function useWrapSol(options: UseWrapSolOptions = {}): Readonly<{
+ balance: WsolBalance | null;
+ error: unknown;
+ helper: WsolHelper;
+ isFetching: boolean;
+ isUnwrapping: boolean;
+ isWrapping: boolean;
+ owner: string | null;
+ refresh(): Promise;
+ refreshing: boolean;
+ resetUnwrap(): void;
+ resetWrap(): void;
+ unwrap(config: Omit, options?: SolTransferSendOptions): Promise;
+ unwrapError: unknown;
+ unwrapSignature: WsolUnwrapSignature | null;
+ unwrapStatus: AsyncState['status'];
+ wrap(config: Omit, options?: SolTransferSendOptions): Promise;
+ wrapError: unknown;
+ wrapSignature: WsolWrapSignature | null;
+ wrapStatus: AsyncState['status'];
+ status: 'disconnected' | 'error' | 'loading' | 'ready';
+}> {
+ const client = useSolanaClient();
+ const session = useWalletSession();
+ const suspense = Boolean(useQuerySuspensePreference());
+ const helper = client.wsol;
+
+ const ownerRaw = options.owner ?? session?.account.address;
+ const owner = useMemo(() => (ownerRaw ? String(ownerRaw) : null), [ownerRaw]);
+
+ const balanceKey = owner ? ['wsol-balance', owner, options.commitment ?? null] : null;
+
+ const fetchBalance = useCallback(() => {
+ if (!owner) {
+ throw new Error('Unable to fetch wSOL balance without an owner.');
+ }
+ return helper.fetchWsolBalance(owner, options.commitment);
+ }, [helper, owner, options.commitment]);
+
+ const swrOptions = useMemo(
+ () => ({
+ revalidateOnFocus: options.revalidateOnFocus ?? false,
+ suspense,
+ ...(options.swr ?? {}),
+ }),
+ [options.revalidateOnFocus, options.swr, suspense],
+ );
+
+ const { data, error, isLoading, isValidating, mutate } = useSWR(balanceKey, fetchBalance, swrOptions);
+
+ const sessionRef = useRef(session);
+ useEffect(() => {
+ sessionRef.current = session;
+ }, [session]);
+
+ const ownerRef = useRef(owner);
+ useEffect(() => {
+ ownerRef.current = owner;
+ }, [owner]);
+
+ const controller = useMemo(
+ () =>
+ createWsolController({
+ authorityProvider: () => sessionRef.current ?? undefined,
+ helper,
+ }),
+ [helper],
+ );
+
+ const wrapState = useSyncExternalStore>(
+ controller.subscribeWrap,
+ controller.getWrapState,
+ controller.getWrapState,
+ );
+
+ const unwrapState = useSyncExternalStore>(
+ controller.subscribeUnwrap,
+ controller.getUnwrapState,
+ controller.getUnwrapState,
+ );
+
+ const refresh = useCallback(() => {
+ if (!owner) {
+ return Promise.resolve(undefined);
+ }
+ return mutate(() => helper.fetchWsolBalance(owner, options.commitment), { revalidate: false });
+ }, [helper, mutate, owner, options.commitment]);
+
+ const wrap = useCallback(
+ async (config: Omit, sendOptions?: SolTransferSendOptions) => {
+ const fullConfig: WsolWrapInput = ownerRef.current ? { ...config, owner: ownerRef.current } : config;
+ const signature = await controller.wrap(fullConfig, sendOptions);
+ if (owner) {
+ await mutate(() => helper.fetchWsolBalance(owner, options.commitment), { revalidate: false });
+ }
+ return signature;
+ },
+ [controller, helper, mutate, options.commitment, owner],
+ );
+
+ const unwrap = useCallback(
+ async (config: Omit, sendOptions?: SolTransferSendOptions) => {
+ const fullConfig: WsolUnwrapInput = ownerRef.current ? { ...config, owner: ownerRef.current } : config;
+ const signature = await controller.unwrap(fullConfig, sendOptions);
+ if (owner) {
+ await mutate(() => helper.fetchWsolBalance(owner, options.commitment), { revalidate: false });
+ }
+ return signature;
+ },
+ [controller, helper, mutate, options.commitment, owner],
+ );
+
+ const resetWrap = useCallback(() => {
+ controller.resetWrap();
+ }, [controller]);
+
+ const resetUnwrap = useCallback(() => {
+ controller.resetUnwrap();
+ }, [controller]);
+
+ const status: 'disconnected' | 'error' | 'loading' | 'ready' =
+ owner === null ? 'disconnected' : error ? 'error' : isLoading && !data ? 'loading' : 'ready';
+
+ return {
+ balance: data ?? null,
+ error: error ?? null,
+ helper,
+ isFetching: Boolean(owner) && (isLoading || isValidating),
+ isUnwrapping: unwrapState.status === 'loading',
+ isWrapping: wrapState.status === 'loading',
+ owner,
+ refresh,
+ refreshing: Boolean(owner) && isValidating,
+ resetUnwrap,
+ resetWrap,
+ unwrap,
+ unwrapError: unwrapState.error ?? null,
+ unwrapSignature: unwrapState.data ?? null,
+ unwrapStatus: unwrapState.status,
+ wrap,
+ wrapError: wrapState.error ?? null,
+ wrapSignature: wrapState.data ?? null,
+ wrapStatus: wrapState.status,
+ status,
+ };
+}
+
export type UseSignatureStatusOptions = Readonly<{
config?: SignatureStatusConfig;
disabled?: boolean;
@@ -1286,3 +1461,6 @@ export type UseLookupTableReturnType = ReturnType;
export type UseNonceAccountParameters = Readonly<{ address?: AddressLike; options?: UseNonceAccountOptions }>;
export type UseNonceAccountReturnType = ReturnType;
+
+export type UseWrapSolParameters = Readonly<{ options?: UseWrapSolOptions }>;
+export type UseWrapSolReturnType = ReturnType;
diff --git a/packages/react-hooks/src/index.ts b/packages/react-hooks/src/index.ts
index 95ea9a4..b3274f4 100644
--- a/packages/react-hooks/src/index.ts
+++ b/packages/react-hooks/src/index.ts
@@ -45,6 +45,8 @@ export type {
UseWalletReturnType,
UseWalletSessionParameters,
UseWalletSessionReturnType,
+ UseWrapSolParameters,
+ UseWrapSolReturnType,
} from './hooks';
export {
useAccount,
@@ -65,6 +67,7 @@ export {
useWallet,
useWalletActions,
useWalletSession,
+ useWrapSol,
} from './hooks';
export { SolanaQueryProvider } from './QueryProvider';
export type { QueryStatus, SolanaQueryResult, UseSolanaRpcQueryOptions } from './query';