From 82014096e796f5239e9abba187b1ffaad184d7de Mon Sep 17 00:00:00 2001 From: guibibeau Date: Mon, 5 Jan 2026 15:11:28 +0700 Subject: [PATCH] feat: add wrapSol/unwrapSol helpers for wSOL operations Add 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 - 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 Also adds WrapSolPanel example component to vite-react demo. Closes #116 --- .changeset/add-wsol-wrap-unwrap-helpers.md | 33 ++ examples/vite-react/src/App.tsx | 2 + .../src/components/WrapSolPanel.tsx | 223 ++++++++++ packages/client/src/client/createClient.ts | 3 + .../client/src/client/createClientHelpers.ts | 25 ++ .../client/src/controllers/wsolController.ts | 138 ++++++ packages/client/src/features/wsol.ts | 393 ++++++++++++++++++ packages/client/src/index.ts | 15 + packages/client/src/types.ts | 3 + packages/react-hooks/src/hooks.ts | 178 ++++++++ packages/react-hooks/src/index.ts | 3 + 11 files changed, 1016 insertions(+) create mode 100644 .changeset/add-wsol-wrap-unwrap-helpers.md create mode 100644 examples/vite-react/src/components/WrapSolPanel.tsx create mode 100644 packages/client/src/controllers/wsolController.ts create mode 100644 packages/client/src/features/wsol.ts 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 */} +
+
+
+ + setWrapAmount(event.target.value)} + placeholder="0.1" + step="0.000000001" + type="number" + value={wrapAmount} + /> +
+
+ + +
+
+
+
+ {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';