Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/client/src/client/createClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ export function createClient(config: SolanaClientConfig): SolanaClient {
get transaction() {
return helpers.transaction;
},
get wsol() {
return helpers.wsol;
},
prepareTransaction: helpers.prepareTransaction,
watchers,
};
Expand Down
23 changes: 23 additions & 0 deletions packages/client/src/client/createClientHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -156,6 +176,9 @@ export function createClientHelpers(runtime: SolanaClientRuntime, store: ClientS
get transaction() {
return getTransaction();
},
get wsol() {
return getWsol();
},
prepareTransaction: prepareTransactionWithRuntime,
});
}
225 changes: 225 additions & 0 deletions packages/client/src/features/wsol.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> }) =>
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');
});
});
});
Loading