diff --git a/packages/client/README.md b/packages/client/README.md index 0a9d4dd..efd307e 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -112,6 +112,26 @@ const signature = await usdc.sendTransfer({ console.log(signature.toString()); ``` +### Wrap and unwrap SOL (wSOL) + +```ts +const wallet = client.store.getState().wallet; +if (wallet.status !== "connected") throw new Error("Connect wallet first"); + +// Wrap 0.1 SOL into wSOL +const wrapSignature = await client.wsol.wrapSol({ + amount: 100_000_000n, // 0.1 SOL in lamports + authority: wallet.session, +}); +console.log(`Wrapped SOL: ${wrapSignature.toString()}`); + +// Unwrap wSOL back to native SOL +const unwrapSignature = await client.wsol.unwrapSol({ + authority: wallet.session, +}); +console.log(`Unwrapped SOL: ${unwrapSignature.toString()}`); +``` + ### Fetch address lookup tables ```ts diff --git a/packages/client/src/client/createClient.ts b/packages/client/src/client/createClient.ts index 44b9ff5..517a0bc 100644 --- a/packages/client/src/client/createClient.ts +++ b/packages/client/src/client/createClient.ts @@ -99,6 +99,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 1a212a3..0deb546 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 PrepareTransactionMessage, type PrepareTransactionOptions, @@ -71,6 +72,17 @@ function wrapStakeHelper(helper: StakeHelper, getFallback: () => Commitment): St }; } +function wrapWsolHelper(helper: WsolHelper, getFallback: () => Commitment): WsolHelper { + return { + prepareUnwrapSol: (config) => helper.prepareUnwrapSol(withDefaultCommitment(config, getFallback)), + prepareWrapSol: (config) => helper.prepareWrapSol(withDefaultCommitment(config, getFallback)), + sendPreparedUnwrapSol: helper.sendPreparedUnwrapSol, + sendPreparedWrapSol: helper.sendPreparedWrapSol, + unwrapSol: (config, options) => helper.unwrapSol(withDefaultCommitment(config, getFallback), options), + wrapSol: (config, options) => helper.wrapSol(withDefaultCommitment(config, getFallback), options), + }; +} + function normaliseConfigValue(value: unknown): string | undefined { if (value === null || value === undefined) { return undefined; @@ -100,6 +112,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) { @@ -122,6 +135,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); @@ -156,6 +176,9 @@ export function createClientHelpers(runtime: SolanaClientRuntime, store: ClientS get transaction() { return getTransaction(); }, + get wsol() { + return getWsol(); + }, prepareTransaction: prepareTransactionWithRuntime, }); } diff --git a/packages/client/src/features/wsol.test.ts b/packages/client/src/features/wsol.test.ts new file mode 100644 index 0000000..c528f93 --- /dev/null +++ b/packages/client/src/features/wsol.test.ts @@ -0,0 +1,225 @@ +import type { TransactionSigner } from '@solana/kit'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WalletSession } from '../types'; + +type MutableMessage = { + instructions: unknown[]; + feePayer?: unknown; + lifetime?: unknown; +}; + +const addressMock = vi.hoisted(() => vi.fn((value: string) => `addr:${value}`)); +const appendTransactionMessageInstructionMock = vi.hoisted(() => + vi.fn((instruction: unknown, message: MutableMessage) => { + message.instructions.push(instruction); + return message; + }), +); +const createTransactionMessageMock = vi.hoisted(() => + vi.fn(() => ({ instructions: [] as unknown[], steps: [] as unknown[] })), +); +const setTransactionMessageFeePayerMock = vi.hoisted(() => + vi.fn((payer: unknown, message: MutableMessage) => { + message.feePayer = payer; + return message; + }), +); +const setTransactionMessageLifetimeUsingBlockhashMock = vi.hoisted(() => + vi.fn((lifetime: unknown, message: MutableMessage) => { + message.lifetime = lifetime; + return message; + }), +); +const signTransactionMessageWithSignersMock = vi.hoisted(() => vi.fn(async () => ({ signed: true }))); +const signAndSendTransactionMessageWithSignersMock = vi.hoisted(() => vi.fn(async () => new Uint8Array([1, 2, 3]))); +const getBase64EncodedWireTransactionMock = vi.hoisted(() => vi.fn(() => 'wire-data')); +const signatureMock = vi.hoisted(() => vi.fn((value: unknown) => `signature:${String(value)}`)); +const pipeMock = vi.hoisted(() => + vi.fn((initial: unknown, ...fns: Array<(value: unknown) => unknown>) => fns.reduce((acc, fn) => fn(acc), initial)), +); +const isTransactionSendingSignerMock = vi.hoisted(() => + vi.fn((signer: { sendTransactions?: unknown }) => Boolean(signer?.sendTransactions)), +); +const isWalletSessionMock = vi.hoisted(() => + vi.fn((value: unknown) => Boolean((value as WalletSession | undefined)?.session)), +); +const createWalletTransactionSignerMock = vi.hoisted(() => + vi.fn((session: { account: { address: unknown } }) => ({ + mode: 'partial' as const, + signer: { address: session.account.address } as TransactionSigner, + })), +); +const resolveSignerModeMock = vi.hoisted(() => vi.fn(() => 'partial')); +const getBase58DecoderMock = vi.hoisted(() => vi.fn(() => ({ decode: () => 'decoded-signature' }))); +const createTransactionPlanExecutorMock = vi.hoisted(() => + vi.fn((config: { executeTransactionMessage: (message: MutableMessage) => Promise }) => + vi.fn(async (plan: { message: MutableMessage }) => { + await config.executeTransactionMessage(plan.message); + return { kind: 'single', message: plan.message }; + }), + ), +); +const singleTransactionPlanMock = vi.hoisted(() => vi.fn((message: MutableMessage) => ({ kind: 'single', message }))); +const findAssociatedTokenPdaMock = vi.hoisted(() => vi.fn(async () => ['ata-address', 'bump'])); +const getCreateAssociatedTokenInstructionMock = vi.hoisted(() => + vi.fn((config: unknown) => ({ instruction: 'createATA', config })), +); +const getSyncNativeInstructionMock = vi.hoisted(() => + vi.fn((config: unknown) => ({ instruction: 'syncNative', config })), +); +const getCloseAccountInstructionMock = vi.hoisted(() => + vi.fn((config: unknown) => ({ instruction: 'closeAccount', config })), +); +const getTransferSolInstructionMock = vi.hoisted(() => + vi.fn((config: unknown) => ({ instruction: 'transferSol', config })), +); + +vi.mock('@solana/kit', () => ({ + address: addressMock, + appendTransactionMessageInstruction: appendTransactionMessageInstructionMock, + createTransactionMessage: createTransactionMessageMock, + createTransactionPlanExecutor: createTransactionPlanExecutorMock, + getBase64EncodedWireTransaction: getBase64EncodedWireTransactionMock, + isTransactionSendingSigner: isTransactionSendingSignerMock, + pipe: pipeMock, + setTransactionMessageFeePayer: setTransactionMessageFeePayerMock, + setTransactionMessageLifetimeUsingBlockhash: setTransactionMessageLifetimeUsingBlockhashMock, + singleTransactionPlan: singleTransactionPlanMock, + signAndSendTransactionMessageWithSigners: signAndSendTransactionMessageWithSignersMock, + signature: signatureMock, + signTransactionMessageWithSigners: signTransactionMessageWithSignersMock, +})); + +vi.mock('@solana/codecs-strings', () => ({ + getBase58Decoder: getBase58DecoderMock, +})); + +vi.mock('@solana-program/token', () => ({ + findAssociatedTokenPda: findAssociatedTokenPdaMock, + getCreateAssociatedTokenInstruction: getCreateAssociatedTokenInstructionMock, + getSyncNativeInstruction: getSyncNativeInstructionMock, + getCloseAccountInstruction: getCloseAccountInstructionMock, + TOKEN_PROGRAM_ADDRESS: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', +})); + +vi.mock('@solana-program/system', () => ({ + getTransferSolInstruction: getTransferSolInstructionMock, +})); + +vi.mock('../signers/walletTransactionSigner', () => ({ + createWalletTransactionSigner: createWalletTransactionSignerMock, + isWalletSession: isWalletSessionMock, + resolveSignerMode: resolveSignerModeMock, +})); + +let createWsolHelper: typeof import('./wsol')['createWsolHelper']; + +beforeAll(async () => { + ({ createWsolHelper } = await import('./wsol')); +}); + +describe('createWsolHelper', () => { + const runtime = { + rpc: { + getAccountInfo: vi.fn(() => ({ + send: vi.fn().mockResolvedValue({ value: null }), // ATA doesn't exist by default + })), + getLatestBlockhash: vi.fn(() => ({ + send: vi.fn().mockResolvedValue({ value: { blockhash: 'hash', lastValidBlockHeight: 123n } }), + })), + sendTransaction: vi.fn(() => ({ + send: vi.fn().mockResolvedValue('wire-signature'), + })), + }, + rpcSubscriptions: {} as never, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('wrapSol', () => { + it('prepares wrap transaction with wallet session', async () => { + const helper = createWsolHelper(runtime as never); + const session = { + session: true, + account: { address: 'owner' }, + } as unknown as WalletSession; + createWalletTransactionSignerMock.mockReturnValueOnce({ + mode: 'partial', + signer: { address: 'fee-payer' } as TransactionSigner, + }); + + const prepared = await helper.prepareWrapSol({ + amount: 100_000_000n, + authority: session, + }); + + expect(createWalletTransactionSignerMock).toHaveBeenCalledWith(session, { commitment: undefined }); + expect(runtime.rpc.getLatestBlockhash).toHaveBeenCalled(); + expect(prepared.amount).toBe(100_000_000n); + expect(prepared.mode).toBe('partial'); + expect(findAssociatedTokenPdaMock).toHaveBeenCalled(); + }); + + it('includes create ATA instruction when account does not exist', async () => { + const helper = createWsolHelper(runtime as never); + const signer = { address: 'payer' } as TransactionSigner; + + await helper.prepareWrapSol({ + amount: 100_000_000n, + authority: signer, + }); + + expect(runtime.rpc.getAccountInfo).toHaveBeenCalled(); + expect(getCreateAssociatedTokenInstructionMock).toHaveBeenCalled(); + expect(getTransferSolInstructionMock).toHaveBeenCalled(); + expect(getSyncNativeInstructionMock).toHaveBeenCalled(); + }); + + it('wraps SOL end-to-end', async () => { + const helper = createWsolHelper(runtime as never); + const signature = await helper.wrapSol({ + amount: 100_000_000n, + authority: { address: 'payer' } as TransactionSigner, + }); + + expect(signTransactionMessageWithSignersMock).toHaveBeenCalled(); + expect(signature).toBe('signature:wire-signature'); + }); + }); + + describe('unwrapSol', () => { + it('prepares unwrap transaction with wallet session', async () => { + const helper = createWsolHelper(runtime as never); + const session = { + session: true, + account: { address: 'owner' }, + } as unknown as WalletSession; + createWalletTransactionSignerMock.mockReturnValueOnce({ + mode: 'partial', + signer: { address: 'fee-payer' } as TransactionSigner, + }); + + const prepared = await helper.prepareUnwrapSol({ + authority: session, + }); + + expect(createWalletTransactionSignerMock).toHaveBeenCalledWith(session, { commitment: undefined }); + expect(runtime.rpc.getLatestBlockhash).toHaveBeenCalled(); + expect(prepared.mode).toBe('partial'); + expect(findAssociatedTokenPdaMock).toHaveBeenCalled(); + expect(getCloseAccountInstructionMock).toHaveBeenCalled(); + }); + + it('unwraps SOL end-to-end', async () => { + const helper = createWsolHelper(runtime as never); + const signature = await helper.unwrapSol({ + authority: { address: 'payer' } as TransactionSigner, + }); + + expect(signTransactionMessageWithSignersMock).toHaveBeenCalled(); + expect(signature).toBe('signature:wire-signature'); + }); + }); +}); diff --git a/packages/client/src/features/wsol.ts b/packages/client/src/features/wsol.ts new file mode 100644 index 0000000..ab84c35 --- /dev/null +++ b/packages/client/src/features/wsol.ts @@ -0,0 +1,381 @@ +import { getBase58Decoder } from '@solana/codecs-strings'; +import { + type Address, + address, + appendTransactionMessageInstruction, + type Blockhash, + type Commitment, + createTransactionMessage, + createTransactionPlanExecutor, + getBase64EncodedWireTransaction, + isSolanaError, + isTransactionSendingSigner, + pipe, + SOLANA_ERROR__TRANSACTION_ERROR__ALREADY_PROCESSED, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + signAndSendTransactionMessageWithSigners, + signature, + signTransactionMessageWithSigners, + singleTransactionPlan, + type TransactionSigner, + type TransactionVersion, +} from '@solana/kit'; +import { getTransferSolInstruction } from '@solana-program/system'; +import { + findAssociatedTokenPda, + getCloseAccountInstruction, + getCreateAssociatedTokenInstruction, + getSyncNativeInstruction, + TOKEN_PROGRAM_ADDRESS, +} from '@solana-program/token'; + +import { createWalletTransactionSigner, isWalletSession, resolveSignerMode } from '../signers/walletTransactionSigner'; +import type { SolanaClientRuntime, WalletSession } from '../types'; +import type { SolTransferSendOptions } from './sol'; + +type BlockhashLifetime = Readonly<{ + blockhash: Blockhash; + lastValidBlockHeight: bigint; +}>; + +type WsolAuthority = TransactionSigner | WalletSession; + +type SignableWsolTransactionMessage = Parameters[0]; + +/** wSOL Native Mint Address: So11111111111111111111111111111111111111112 */ +export const WSOL_MINT_ADDRESS = 'So11111111111111111111111111111111111111112' as Address; + +export type WrapSolConfig = Readonly<{ + amount: bigint; + authority: WsolAuthority; + commitment?: Commitment; + lifetime?: BlockhashLifetime; + owner?: Address | string; + transactionVersion?: TransactionVersion; +}>; + +export type UnwrapSolConfig = Readonly<{ + authority: WsolAuthority; + commitment?: Commitment; + lifetime?: BlockhashLifetime; + owner?: Address | string; + transactionVersion?: TransactionVersion; +}>; + +type PreparedWrapSol = Readonly<{ + amount: bigint; + ataAddress: Address; + commitment?: Commitment; + lifetime: BlockhashLifetime; + message: SignableWsolTransactionMessage; + mode: 'partial' | 'send'; + signer: TransactionSigner; +}>; + +type PreparedUnwrapSol = Readonly<{ + ataAddress: Address; + commitment?: Commitment; + lifetime: BlockhashLifetime; + message: SignableWsolTransactionMessage; + mode: 'partial' | 'send'; + 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 }; +} + +export type WsolHelper = Readonly<{ + prepareWrapSol(config: WrapSolConfig): Promise; + prepareUnwrapSol(config: UnwrapSolConfig): Promise; + sendPreparedWrapSol( + prepared: PreparedWrapSol, + options?: SolTransferSendOptions, + ): Promise>; + sendPreparedUnwrapSol( + prepared: PreparedUnwrapSol, + options?: SolTransferSendOptions, + ): Promise>; + wrapSol(config: WrapSolConfig, options?: SolTransferSendOptions): Promise>; + unwrapSol(config: UnwrapSolConfig, options?: SolTransferSendOptions): Promise>; +}>; + +/** Creates helpers for wrapping native SOL into wSOL and unwrapping it back. */ +export function createWsolHelper(runtime: SolanaClientRuntime): WsolHelper { + const mintAddress = address(WSOL_MINT_ADDRESS); + const tokenProgram = address(TOKEN_PROGRAM_ADDRESS); + + async function prepareWrapSol(config: WrapSolConfig): 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 findAssociatedTokenPda({ + mint: mintAddress, + owner, + tokenProgram, + }); + + const instructionList: Parameters[0][] = []; + + // Check if ATA exists + const { value } = await runtime.rpc + .getAccountInfo(ataAddress, { + commitment, + dataSlice: { length: 0, offset: 0 }, + encoding: 'base64', + }) + .send(); + + if (!value) { + // Create ATA if it doesn't exist + instructionList.push( + getCreateAssociatedTokenInstruction({ + ata: ataAddress, + mint: mintAddress, + owner, + payer: signer, + tokenProgram, + }), + ); + } + + // Transfer SOL to the ATA + instructionList.push( + getTransferSolInstruction({ + amount: config.amount, + destination: ataAddress, + source: signer, + }), + ); + + // Sync native instruction + instructionList.push( + getSyncNativeInstruction({ + account: ataAddress, + }), + ); + + let message: SignableWsolTransactionMessage = pipe( + createTransactionMessage({ version: config.transactionVersion ?? 0 }), + (m) => setTransactionMessageFeePayer(signer.address, m), + (m) => setTransactionMessageLifetimeUsingBlockhash(lifetime, m), + ); + + for (const instruction of instructionList) { + message = appendTransactionMessageInstruction(instruction, message); + } + + return { + amount: config.amount, + ataAddress, + commitment, + lifetime, + message, + mode, + signer, + }; + } + + async function prepareUnwrapSol(config: UnwrapSolConfig): 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 findAssociatedTokenPda({ + mint: mintAddress, + owner, + tokenProgram, + }); + + // Close the wSOL account, which unwraps SOL back to the owner + const instruction = getCloseAccountInstruction({ + account: ataAddress, + destination: owner, + owner: signer, + }); + + const message: SignableWsolTransactionMessage = 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, + signer, + }; + } + + async function sendPreparedWrapSol( + prepared: PreparedWrapSol, + 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(singleTransactionPlan(prepared.message), { abortSignal: options.abortSignal }); + if (!latestSignature) { + throw new Error('Failed to resolve transaction signature.'); + } + return latestSignature; + } + + async function sendPreparedUnwrapSol( + prepared: PreparedUnwrapSol, + 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(singleTransactionPlan(prepared.message), { abortSignal: options.abortSignal }); + if (!latestSignature) { + throw new Error('Failed to resolve transaction signature.'); + } + return latestSignature; + } + + async function wrapSol( + config: WrapSolConfig, + options?: SolTransferSendOptions, + ): Promise> { + const prepared = await prepareWrapSol(config); + try { + return await sendPreparedWrapSol(prepared, options); + } catch (error) { + if (isSolanaError(error, SOLANA_ERROR__TRANSACTION_ERROR__ALREADY_PROCESSED)) { + const retriedPrepared = await prepareWrapSol({ ...config, lifetime: undefined }); + return await sendPreparedWrapSol(retriedPrepared, options); + } + throw error; + } + } + + async function unwrapSol( + config: UnwrapSolConfig, + options?: SolTransferSendOptions, + ): Promise> { + const prepared = await prepareUnwrapSol(config); + try { + return await sendPreparedUnwrapSol(prepared, options); + } catch (error) { + if (isSolanaError(error, SOLANA_ERROR__TRANSACTION_ERROR__ALREADY_PROCESSED)) { + const retriedPrepared = await prepareUnwrapSol({ ...config, lifetime: undefined }); + return await sendPreparedUnwrapSol(retriedPrepared, options); + } + throw error; + } + } + + return { + prepareUnwrapSol, + prepareWrapSol, + sendPreparedUnwrapSol, + sendPreparedWrapSol, + unwrapSol, + wrapSol, + }; +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ee5a13a..09e5233 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -75,6 +75,13 @@ export { type TransactionSendOptions, type TransactionSignOptions, } from './features/transactions'; +export { + createWsolHelper, + type UnwrapSolConfig, + type WrapSolConfig, + WSOL_MINT_ADDRESS, + type WsolHelper, +} from './features/wsol'; export { createTokenAmount, type FormatAmountOptions, diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 9a6d75b..a4e96eb 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 { PrepareTransactionMessage, PrepareTransactionOptions } from './transactions/prepareTransaction'; import type { ClusterMoniker } from './utils/cluster'; @@ -345,6 +346,7 @@ export type ClientHelpers = Readonly<{ splToken(config: SplTokenHelperConfig): SplTokenHelper; stake: StakeHelper; transaction: TransactionHelper; + wsol: WsolHelper; prepareTransaction( config: PrepareTransactionOptions, ): Promise; @@ -371,5 +373,6 @@ export type SolanaClient = Readonly<{ SplHelper(config: SplTokenHelperConfig): SplTokenHelper; stake: StakeHelper; transaction: TransactionHelper; + wsol: WsolHelper; prepareTransaction: ClientHelpers['prepareTransaction']; }>; diff --git a/packages/react-hooks/README.md b/packages/react-hooks/README.md index 808d58b..3d8488c 100644 --- a/packages/react-hooks/README.md +++ b/packages/react-hooks/README.md @@ -138,6 +138,39 @@ function TokenPanel({ } ``` +### Wrap and unwrap SOL (wSOL) + +```tsx +import { useWrapSol, useUnwrapSol } from "@solana/react-hooks"; + +function WsolPanel() { + const { wrap, isWrapping, signature: wrapSignature, error: wrapError } = + useWrapSol(); + const { + unwrap, + isUnwrapping, + signature: unwrapSignature, + error: unwrapError, + } = useUnwrapSol(); + + return ( +
+ + {wrapSignature &&

Wrapped: {wrapSignature.toString()}

} + {wrapError &&

Wrap error

} + + + {unwrapSignature &&

Unwrapped: {unwrapSignature.toString()}

} + {unwrapError &&

Unwrap error

} +
+ ); +} +``` + ### Fetch address lookup tables ```tsx diff --git a/packages/react-hooks/src/hooks.ts b/packages/react-hooks/src/hooks.ts index 4567c16..4e4f9fd 100644 --- a/packages/react-hooks/src/hooks.ts +++ b/packages/react-hooks/src/hooks.ts @@ -45,10 +45,13 @@ import { toAddress, type UnstakeInput, type UnstakeSendOptions, + type UnwrapSolConfig, type WalletSession, type WalletStatus, type WithdrawInput, type WithdrawSendOptions, + type WrapSolConfig, + type WsolHelper, } from '@solana/client'; import type { Commitment, Lamports, Signature } from '@solana/kit'; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; @@ -606,6 +609,147 @@ export function useSplToken( }; } +type WrapSolSignature = UnwrapPromise>; +type UnwrapSolSignature = UnwrapPromise>; + +/** + * Convenience wrapper for wrapping native SOL into wSOL (Wrapped SOL). + * Wrapping SOL creates an Associated Token Account for wSOL and transfers SOL into it. + * + * @example + * ```ts + * const { wrap, isWrapping, signature, error } = useWrapSol(); + * // Wrap 0.1 SOL + * await wrap({ amount: 100_000_000n }); + * ``` + */ +export function useWrapSol(): Readonly<{ + error: unknown; + helper: WsolHelper; + isWrapping: boolean; + reset(): void; + signature: WrapSolSignature | null; + status: AsyncState['status']; + wrap( + config: Omit, + options?: SolTransferSendOptions, + ): Promise; +}> { + const client = useSolanaClient(); + const session = useWalletSession(); + const helper = client.wsol; + + const [state, setState] = useState>(createInitialAsyncState()); + + const wrap = useCallback( + async (config: Omit, options?: SolTransferSendOptions) => { + if (!session) { + throw new Error('Connect a wallet before wrapping SOL.'); + } + setState(createAsyncState('loading')); + try { + const signature = await helper.wrapSol( + { + ...config, + authority: session, + owner: session.account.address, + }, + options, + ); + setState(createAsyncState('success', { data: signature })); + return signature; + } catch (error) { + setState(createAsyncState('error', { error })); + throw error; + } + }, + [helper, session], + ); + + const reset = useCallback(() => { + setState(createInitialAsyncState()); + }, []); + + return { + error: state.error ?? null, + helper, + isWrapping: state.status === 'loading', + reset, + signature: state.data ?? null, + status: state.status, + wrap, + }; +} + +/** + * Convenience wrapper for unwrapping wSOL (Wrapped SOL) back to native SOL. + * Unwrapping closes the wSOL Associated Token Account and returns SOL to the wallet. + * + * @example + * ```ts + * const { unwrap, isUnwrapping, signature, error } = useUnwrapSol(); + * // Unwrap all wSOL back to native SOL + * await unwrap({}); + * ``` + */ +export function useUnwrapSol(): Readonly<{ + error: unknown; + helper: WsolHelper; + isUnwrapping: boolean; + reset(): void; + signature: UnwrapSolSignature | null; + status: AsyncState['status']; + unwrap( + config: Omit, + options?: SolTransferSendOptions, + ): Promise; +}> { + const client = useSolanaClient(); + const session = useWalletSession(); + const helper = client.wsol; + + const [state, setState] = useState>(createInitialAsyncState()); + + const unwrap = useCallback( + async (config: Omit, options?: SolTransferSendOptions) => { + if (!session) { + throw new Error('Connect a wallet before unwrapping SOL.'); + } + setState(createAsyncState('loading')); + try { + const signature = await helper.unwrapSol( + { + ...config, + authority: session, + owner: session.account.address, + }, + options, + ); + setState(createAsyncState('success', { data: signature })); + return signature; + } catch (error) { + setState(createAsyncState('error', { error })); + throw error; + } + }, + [helper, session], + ); + + const reset = useCallback(() => { + setState(createInitialAsyncState()); + }, []); + + return { + error: state.error ?? null, + helper, + isUnwrapping: state.status === 'loading', + reset, + signature: state.data ?? null, + status: state.status, + unwrap, + }; +} + /** * Subscribe to the account cache for a given address, optionally triggering fetch & watch helpers. * @@ -1255,3 +1399,9 @@ export type UseLookupTableReturnType = ReturnType; export type UseNonceAccountParameters = Readonly<{ address?: AddressLike; options?: UseNonceAccountOptions }>; export type UseNonceAccountReturnType = ReturnType; + +export type UseWrapSolParameters = undefined; +export type UseWrapSolReturnType = ReturnType; + +export type UseUnwrapSolParameters = undefined; +export type UseUnwrapSolReturnType = ReturnType; diff --git a/packages/react-hooks/src/index.ts b/packages/react-hooks/src/index.ts index c3e0e35..1552f1c 100644 --- a/packages/react-hooks/src/index.ts +++ b/packages/react-hooks/src/index.ts @@ -36,6 +36,8 @@ export type { UseSplTokenReturnType, UseTransactionPoolParameters, UseTransactionPoolReturnType, + UseUnwrapSolParameters, + UseUnwrapSolReturnType, UseWaitForSignatureOptions, UseWaitForSignatureParameters, UseWaitForSignatureReturnType, @@ -45,6 +47,8 @@ export type { UseWalletReturnType, UseWalletSessionParameters, UseWalletSessionReturnType, + UseWrapSolParameters, + UseWrapSolReturnType, } from './hooks'; export { useAccount, @@ -61,10 +65,12 @@ export { useSplToken, useStake, useTransactionPool, + useUnwrapSol, useWaitForSignature, useWallet, useWalletActions, useWalletSession, + useWrapSol, } from './hooks'; export { SolanaQueryProvider } from './QueryProvider'; export type { QueryStatus, SolanaQueryResult, UseSolanaRpcQueryOptions } from './query';