diff --git a/examples/vite-react/src/App.tsx b/examples/vite-react/src/App.tsx index 3f687b4..5a1e9bb 100644 --- a/examples/vite-react/src/App.tsx +++ b/examples/vite-react/src/App.tsx @@ -13,6 +13,7 @@ import { SignatureWatcherCard } from './components/SignatureWatcherCard.tsx'; import { SimulateTransactionCard } from './components/SimulateTransactionCard.tsx'; import { SolTransferForm } from './components/SolTransferForm.tsx'; import { SplTokenPanel } from './components/SplTokenPanel.tsx'; +import { StakePanel } from './components/StakePanel.tsx'; import { StoreInspectorCard } from './components/StoreInspectorCard.tsx'; import { TransactionPoolPanel } from './components/TransactionPoolPanel.tsx'; import { Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui/tabs.tsx'; @@ -83,6 +84,7 @@ function DemoApp() { + diff --git a/examples/vite-react/src/components/StakePanel.tsx b/examples/vite-react/src/components/StakePanel.tsx new file mode 100644 index 0000000..e999a39 --- /dev/null +++ b/examples/vite-react/src/components/StakePanel.tsx @@ -0,0 +1,357 @@ +import { LAMPORTS_PER_SOL } from '@solana/client'; +import { type StakeAccount, useStake, useWallet, useWalletSession } from '@solana/react-hooks'; +import { useCallback, useEffect, useState } from 'react'; +import { Button } from './ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'; +import { Input } from './ui/input'; + +const DEVNET_VALIDATOR_ID = 'i7NyKBMJCA9bLM2nsGyAGCKHECuR2L5eh4GqFciuwNT'; // Validator ID get from https://solana.com/docs/rpc/http/getvoteaccounts + +export function StakePanel() { + const [validatorId, setValidatorId] = useState(DEVNET_VALIDATOR_ID); + const [amount, setAmount] = useState('1'); + const [stakeAccounts, setStakeAccounts] = useState([]); + const [loadingAccounts, setLoadingAccounts] = useState(false); + + const wallet = useWallet(); + const session = useWalletSession(); + const { + stake, + unstake, + withdraw, + signature, + unstakeSignature, + withdrawSignature, + status, + unstakeStatus, + withdrawStatus, + error, + unstakeError, + withdrawError, + isStaking, + isUnstaking, + isWithdrawing, + reset, + resetUnstake, + resetWithdraw, + getStakeAccounts, + validatorId: currentValidatorId, + } = useStake(validatorId); + + const handleFetchStakeAccounts = useCallback(async () => { + if (!session) return; + setLoadingAccounts(true); + try { + const accounts = await getStakeAccounts(session.account?.address, validatorId); + setStakeAccounts(accounts); + } catch (err) { + console.error('Failed to fetch stake accounts:', err); + } finally { + setLoadingAccounts(false); + } + }, [session, validatorId, getStakeAccounts]); + + useEffect(() => { + if (session && validatorId) { + handleFetchStakeAccounts(); + } + }, [session, validatorId, handleFetchStakeAccounts]); + + useEffect(() => { + if (status === 'success' && signature) { + const timer = setTimeout(() => { + handleFetchStakeAccounts(); + }, 3000); + return () => clearTimeout(timer); + } + }, [status, signature, handleFetchStakeAccounts]); + + const handleStake = async () => { + try { + const lamports = BigInt(Math.floor(parseFloat(amount) * Number(LAMPORTS_PER_SOL))); + + await stake({ + amount: lamports, + }); + } catch (err) { + console.error('Stake failed:', err); + } + }; + + const handleUnstake = async (stakeAccount: string) => { + try { + await unstake({ + stakeAccount, + }); + + setTimeout(() => { + handleFetchStakeAccounts(); + }, 3000); + } catch (err) { + console.error('Unstake failed:', err); + } + }; + + const handleWithdraw = async (stakeAccount: string, amount: bigint, destination: string) => { + try { + await withdraw({ + stakeAccount, + destination, + amount, + }); + + setTimeout(() => { + handleFetchStakeAccounts(); + }, 3000); + } catch (err) { + console.error('Withdraw failed:', err); + } + }; + + const isConnected = wallet.status === 'connected'; + + return ( + + +
+ Stake Native SOL + + Stake your SOL tokens to a validator to earn rewards. This uses the useStake hook. + +
+
+ +
+ + setValidatorId(e.target.value)} + placeholder="Enter validator public key" + disabled={isStaking} + className="font-mono text-sm" + /> +

+ Current: {currentValidatorId.slice(0, 8)}...{currentValidatorId.slice(-8)} +

+
+
+ + setAmount(e.target.value)} + placeholder="0.0" + step="0.1" + min="0" + disabled={isStaking || !isConnected} + /> +
+
+ + {signature && ( + + )} +
+ {!isConnected &&

Connect your wallet to stake SOL

} + {status === 'success' && signature && ( +
+

Stake Successful!

+

+ Signature: {signature} +

+
+ )} + {status === 'error' && error ? ( +
+

Error

+

+ {String(error instanceof Error ? error.message : error)} +

+
+ ) : null} + {status === 'loading' && ( +
+

Processing stake transaction...

+
+ )} + {unstakeStatus === 'success' && unstakeSignature && ( +
+

Unstake Successful!

+

+ Signature: {unstakeSignature} +

+ +
+ )} + {unstakeStatus === 'error' && unstakeError ? ( +
+

Unstake Error

+

+ {String(unstakeError instanceof Error ? unstakeError.message : unstakeError)} +

+
+ ) : null} + {unstakeStatus === 'loading' && ( +
+

Processing unstake transaction...

+
+ )} + {withdrawStatus === 'success' && withdrawSignature && ( +
+

Withdraw Successful!

+

+ Signature: {withdrawSignature} +

+ +
+ )} + {withdrawStatus === 'error' && withdrawError ? ( +
+

Withdraw Error

+

+ {String(withdrawError instanceof Error ? withdrawError.message : withdrawError)} +

+
+ ) : null} + {withdrawStatus === 'loading' && ( +
+

Processing withdraw transaction...

+
+ )}{' '} +
+

+ Stake Status: {status} +

+

+ Unstake Status: {unstakeStatus} +

+

+ Withdraw Status: {withdrawStatus} +

+

+ Is Staking: {isStaking ? 'Yes' : 'No'} +

+

+ Is Unstaking: {isUnstaking ? 'Yes' : 'No'} +

+

+ Is Withdrawing: {isWithdrawing ? 'Yes' : 'No'} +

+ {signature && ( +

+ Last Stake Signature: {signature.slice(0, 20)}... +

+ )} + {unstakeSignature && ( +

+ Last Unstake Signature: {unstakeSignature.slice(0, 20)}... +

+ )} + {withdrawSignature && ( +

+ Last Withdraw Signature: {withdrawSignature.slice(0, 20)}... +

+ )} +
{' '} +
+ + + {stakeAccounts.length > 0 && ( +
+

Found {stakeAccounts.length} stake account(s):

+ {stakeAccounts.map((acc) => { + const stakeAmount = + Number(acc?.account?.data?.parsed?.info?.stake?.delegation?.stake || 0) / + Number(LAMPORTS_PER_SOL); + const deactivationEpoch = + acc?.account?.data?.parsed?.info?.stake?.delegation?.deactivationEpoch || + '18446744073709551615'; + const isDeactivated = deactivationEpoch !== '18446744073709551615'; + + return ( +
+

+ Account: {acc?.pubkey.slice(0, 20)}... +

+

+ Stake: {stakeAmount.toFixed(4)} SOL +

+

+ Voter:{' '} + {acc?.account?.data?.parsed?.info?.stake?.delegation?.voter?.slice(0, 20)} + ... +

+

+ Status:{' '} + + {isDeactivated ? 'Deactivated' : 'Active'} + +

+
+ + +
+ {isDeactivated && ( +

+ Deactivated at epoch {deactivationEpoch}. Wait for cooldown before + withdrawing. +

+ )} +
+ ); + })} +
+ )} +
+
+
+ ); +} diff --git a/packages/client/package.json b/packages/client/package.json index 372f08f..cfe533a 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -70,6 +70,7 @@ }, "license": "MIT", "dependencies": { + "@solana-program/stake": "^0.5.0", "@solana/codecs-strings": "catalog:solana", "@solana/kit": "catalog:solana", "@solana/transactions": "catalog:solana", diff --git a/packages/client/src/client/createClient.ts b/packages/client/src/client/createClient.ts index 32e5d8c..44b9ff5 100644 --- a/packages/client/src/client/createClient.ts +++ b/packages/client/src/client/createClient.ts @@ -93,6 +93,9 @@ export function createClient(config: SolanaClientConfig): SolanaClient { splToken: helpers.splToken, SplToken: helpers.splToken, SplHelper: helpers.splToken, + get stake() { + return helpers.stake; + }, get transaction() { return helpers.transaction; }, diff --git a/packages/client/src/client/createClientHelpers.ts b/packages/client/src/client/createClientHelpers.ts index db9ddb9..1a212a3 100644 --- a/packages/client/src/client/createClientHelpers.ts +++ b/packages/client/src/client/createClientHelpers.ts @@ -2,6 +2,7 @@ import type { Commitment } from '@solana/kit'; 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 { type PrepareTransactionMessage, @@ -55,6 +56,21 @@ function wrapSplTokenHelper( }; } +function wrapStakeHelper(helper: StakeHelper, getFallback: () => Commitment): StakeHelper { + return { + getStakeAccounts: helper.getStakeAccounts, + prepareStake: (config) => helper.prepareStake(withDefaultCommitment(config, getFallback)), + prepareUnstake: (config) => helper.prepareUnstake(withDefaultCommitment(config, getFallback)), + prepareWithdraw: (config) => helper.prepareWithdraw(withDefaultCommitment(config, getFallback)), + sendPreparedStake: helper.sendPreparedStake, + sendPreparedUnstake: helper.sendPreparedUnstake, + sendPreparedWithdraw: helper.sendPreparedWithdraw, + sendStake: (config, options) => helper.sendStake(withDefaultCommitment(config, getFallback), options), + sendUnstake: (config, options) => helper.sendUnstake(withDefaultCommitment(config, getFallback), options), + sendWithdraw: (config, options) => helper.sendWithdraw(withDefaultCommitment(config, getFallback), options), + }; +} + function normaliseConfigValue(value: unknown): string | undefined { if (value === null || value === undefined) { return undefined; @@ -82,6 +98,7 @@ export function createClientHelpers(runtime: SolanaClientRuntime, store: ClientS const getFallbackCommitment = () => store.getState().cluster.commitment; const splTokenCache = new Map(); let solTransfer: SolTransferHelper | undefined; + let stake: StakeHelper | undefined; let transaction: TransactionHelper | undefined; const getSolTransfer = () => { @@ -91,6 +108,13 @@ export function createClientHelpers(runtime: SolanaClientRuntime, store: ClientS return solTransfer; }; + const getStake = () => { + if (!stake) { + stake = wrapStakeHelper(createStakeHelper(runtime), getFallbackCommitment); + } + return stake; + }; + const getTransaction = () => { if (!transaction) { transaction = createTransactionHelper(runtime, getFallbackCommitment); @@ -126,6 +150,9 @@ export function createClientHelpers(runtime: SolanaClientRuntime, store: ClientS return getSolTransfer(); }, splToken: getSplTokenHelper, + get stake() { + return getStake(); + }, get transaction() { return getTransaction(); }, diff --git a/packages/client/src/controllers/stakeController.ts b/packages/client/src/controllers/stakeController.ts new file mode 100644 index 0000000..11f0cf1 --- /dev/null +++ b/packages/client/src/controllers/stakeController.ts @@ -0,0 +1,223 @@ +import type { + StakeHelper, + StakePrepareConfig, + StakeSendOptions, + UnstakePrepareConfig, + UnstakeSendOptions, + WithdrawPrepareConfig, + WithdrawSendOptions, +} from '../features/stake'; +import { type AsyncState, createAsyncState, createInitialAsyncState } from '../state/asyncState'; + +type StakeSignature = Awaited>; +type UnstakeSignature = Awaited>; +type WithdrawSignature = Awaited>; + +type Listener = () => void; + +export type StakeControllerConfig = Readonly<{ + authorityProvider?: () => StakePrepareConfig['authority'] | undefined; + helper: StakeHelper; +}>; + +export type StakeInput = Omit & { + authority?: StakePrepareConfig['authority']; +}; + +export type UnstakeInput = Omit & { + authority?: UnstakePrepareConfig['authority']; +}; + +export type WithdrawInput = Omit & { + authority?: WithdrawPrepareConfig['authority']; +}; + +export type StakeController = Readonly<{ + getHelper(): StakeHelper; + getState(): AsyncState; + getUnstakeState(): AsyncState; + getWithdrawState(): AsyncState; + reset(): void; + resetUnstake(): void; + resetWithdraw(): void; + stake(config: StakeInput, options?: StakeSendOptions): Promise; + unstake(config: UnstakeInput, options?: UnstakeSendOptions): Promise; + withdraw(config: WithdrawInput, options?: WithdrawSendOptions): Promise; + subscribe(listener: Listener): () => void; + subscribeUnstake(listener: Listener): () => void; + subscribeWithdraw(listener: Listener): () => void; +}>; + +function ensureAuthority( + input: StakeInput, + resolveDefault?: () => StakePrepareConfig['authority'] | undefined, +): StakePrepareConfig { + const authority = input.authority ?? resolveDefault?.(); + if (!authority) { + throw new Error('Connect a wallet or supply an `authority` before staking SOL.'); + } + return { + ...input, + authority, + }; +} + +function ensureUnstakeAuthority( + input: UnstakeInput, + resolveDefault?: () => UnstakePrepareConfig['authority'] | undefined, +): UnstakePrepareConfig { + const authority = input.authority ?? resolveDefault?.(); + if (!authority) { + throw new Error('Connect a wallet or supply an `authority` before unstaking SOL.'); + } + return { + ...input, + authority, + }; +} + +function ensureWithdrawAuthority( + input: WithdrawInput, + resolveDefault?: () => WithdrawPrepareConfig['authority'] | undefined, +): WithdrawPrepareConfig { + const authority = input.authority ?? resolveDefault?.(); + if (!authority) { + throw new Error('Connect a wallet or supply an `authority` before withdrawing SOL.'); + } + return { + ...input, + authority, + }; +} + +export function createStakeController(config: StakeControllerConfig): StakeController { + const listeners = new Set(); + const unstakeListeners = new Set(); + const withdrawListeners = new Set(); + const helper = config.helper; + const authorityProvider = config.authorityProvider; + let state: AsyncState = createInitialAsyncState(); + let unstakeState: AsyncState = createInitialAsyncState(); + let withdrawState: AsyncState = createInitialAsyncState(); + + function notify() { + for (const listener of listeners) { + listener(); + } + } + + function notifyUnstake() { + for (const listener of unstakeListeners) { + listener(); + } + } + + function notifyWithdraw() { + for (const listener of withdrawListeners) { + listener(); + } + } + + function setState(next: AsyncState) { + state = next; + notify(); + } + + function setUnstakeState(next: AsyncState) { + unstakeState = next; + notifyUnstake(); + } + + function setWithdrawState(next: AsyncState) { + withdrawState = next; + notifyWithdraw(); + } + + async function stake(config: StakeInput, options?: StakeSendOptions): Promise { + const request = ensureAuthority(config, authorityProvider); + setState(createAsyncState('loading')); + try { + const signature = await helper.sendStake(request, options); + setState(createAsyncState('success', { data: signature })); + return signature; + } catch (error) { + setState(createAsyncState('error', { error })); + throw error; + } + } + + async function unstake(config: UnstakeInput, options?: UnstakeSendOptions): Promise { + const request = ensureUnstakeAuthority(config, authorityProvider); + setUnstakeState(createAsyncState('loading')); + try { + const signature = await helper.sendUnstake(request, options); + setUnstakeState(createAsyncState('success', { data: signature })); + return signature; + } catch (error) { + setUnstakeState(createAsyncState('error', { error })); + throw error; + } + } + + async function withdraw(config: WithdrawInput, options?: WithdrawSendOptions): Promise { + const request = ensureWithdrawAuthority(config, authorityProvider); + setWithdrawState(createAsyncState('loading')); + try { + const signature = await helper.sendWithdraw(request, options); + setWithdrawState(createAsyncState('success', { data: signature })); + return signature; + } catch (error) { + setWithdrawState(createAsyncState('error', { error })); + throw error; + } + } + + function subscribe(listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + } + + function subscribeUnstake(listener: Listener): () => void { + unstakeListeners.add(listener); + return () => { + unstakeListeners.delete(listener); + }; + } + + function subscribeWithdraw(listener: Listener): () => void { + withdrawListeners.add(listener); + return () => { + withdrawListeners.delete(listener); + }; + } + + function reset() { + setState(createInitialAsyncState()); + } + + function resetUnstake() { + setUnstakeState(createInitialAsyncState()); + } + + function resetWithdraw() { + setWithdrawState(createInitialAsyncState()); + } + + return { + getHelper: () => helper, + getState: () => state, + getUnstakeState: () => unstakeState, + getWithdrawState: () => withdrawState, + reset, + resetUnstake, + resetWithdraw, + stake, + unstake, + withdraw, + subscribe, + subscribeUnstake, + subscribeWithdraw, + }; +} diff --git a/packages/client/src/features/stake.test.ts b/packages/client/src/features/stake.test.ts new file mode 100644 index 0000000..862c856 --- /dev/null +++ b/packages/client/src/features/stake.test.ts @@ -0,0 +1,202 @@ +import { address, type Base58EncodedBytes, type Blockhash, type TransactionSigner } from '@solana/kit'; +import { describe, expect, it, vi } from 'vitest'; +import type { SolanaClientRuntime } from '../types'; +import { createStakeHelper, type StakeAccount } from './stake'; + +const VALIDATOR_ID = 'he1iusunGwqrNtafDtLdhsUQDFvo13z9sUa36PauBtk'; +const WALLET = address('8beY2iKosqhApSsWwJ5JTyxzVnMqxarJbYdrHgRUKYPx'); + +const ownerSigner: TransactionSigner = { + address: WALLET, +} satisfies Partial> as TransactionSigner; + +const mkStakeAccount = (voter: string): StakeAccount => ({ + pubkey: address('StakeAccount1111111111111111111111111111111'), + account: { + data: { + parsed: { + info: { + stake: { + delegation: { + voter, + stake: '1000000000', + activationEpoch: '0', + deactivationEpoch: '18446744073709551615', + }, + }, + meta: { + rentExemptReserve: '2282880', + authorized: { + staker: WALLET, + withdrawer: WALLET, + }, + lockup: { + unixTimestamp: 0, + epoch: 0, + custodian: '11111111111111111111111111111111', + }, + }, + }, + }, + }, + lamports: 1_000_000_000n, + }, +}); + +const VALIDATOR_STAKE_ACC = mkStakeAccount(VALIDATOR_ID); +const OTHER_STAKE_ACC = mkStakeAccount('Vote111111111111111111111111111111111111111'); + +const mockRentSend = vi.fn().mockResolvedValue(2_282_880n); +const mockHashSend = vi.fn().mockResolvedValue({ + value: { + blockhash: 'HyPeRblockHash1111111111111111111111111' as Blockhash, + lastValidBlockHeight: 123n, + }, +}); +const mockProgramAccountsSend = vi.fn().mockResolvedValue([VALIDATOR_STAKE_ACC, OTHER_STAKE_ACC]); + +const mockRpc = { + getMinimumBalanceForRentExemption: vi.fn().mockReturnValue({ + send: mockRentSend, + }), + getLatestBlockhash: vi.fn().mockReturnValue({ + send: mockHashSend, + }), + sendTransaction: vi.fn().mockReturnValue({ + send: vi.fn().mockResolvedValue('mockSignature123'), + }), + getProgramAccounts: vi.fn().mockReturnValue({ + send: mockProgramAccountsSend, + }), +}; + +const mockRuntime = { + rpc: mockRpc, + rpcSubscriptions: {}, +} as unknown as SolanaClientRuntime; + +describe('createStakeHelper', () => { + it('creates a stake helper with correct methods', () => { + const helper = createStakeHelper(mockRuntime); + + expect(helper).toBeDefined(); + expect(typeof helper.prepareStake).toBe('function'); + expect(typeof helper.sendPreparedStake).toBe('function'); + expect(typeof helper.sendStake).toBe('function'); + expect(typeof helper.getStakeAccounts).toBe('function'); + }); + + it('prepareStake builds a transaction with correct structure', async () => { + const helper = createStakeHelper(mockRuntime); + const validatorId = address(VALIDATOR_ID); + + const prepared = await helper.prepareStake({ + amount: 1_000_000_000n, // 1 SOL + authority: ownerSigner, + validatorId, + }); + + expect(prepared).toBeDefined(); + expect(prepared.message).toBeDefined(); + expect(prepared.signer).toBe(ownerSigner); + expect(prepared.mode).toBe('partial'); + expect(prepared.stakeAccount).toBeDefined(); + expect(prepared.lifetime).toBeDefined(); + expect(mockRpc.getMinimumBalanceForRentExemption).toHaveBeenCalledWith(BigInt(200)); + expect(mockRpc.getLatestBlockhash).toHaveBeenCalled(); + }); + + it('handles different amount formats', async () => { + const helper = createStakeHelper(mockRuntime); + const validatorId = address(VALIDATOR_ID); + + // Test with number + const prepared1 = await helper.prepareStake({ + amount: 1000000000, // 1 SOL as number + authority: ownerSigner, + validatorId, + }); + expect(prepared1).toBeDefined(); + + // Test with string + const prepared2 = await helper.prepareStake({ + amount: '1000000000', // 1 SOL as string + authority: ownerSigner, + validatorId, + }); + expect(prepared2).toBeDefined(); + }); + + it('uses provided lifetime if available', async () => { + const helper = createStakeHelper(mockRuntime); + const validatorId = address(VALIDATOR_ID); + const customLifetime = { + blockhash: 'CustomBlockhash11111111111111111111111' as Blockhash, + lastValidBlockHeight: 456n, + }; + + mockRpc.getLatestBlockhash.mockClear(); + + const prepared = await helper.prepareStake({ + amount: 1_000_000_000n, + authority: ownerSigner, + validatorId, + lifetime: customLifetime, + }); + + expect(prepared.lifetime).toBe(customLifetime); + expect(mockRpc.getLatestBlockhash).not.toHaveBeenCalled(); + }); + + it('getStakeAccounts returns all stake accounts for a wallet', async () => { + const helper = createStakeHelper(mockRuntime); + + const accounts = await helper.getStakeAccounts(WALLET); + + expect(mockRpc.getProgramAccounts).toHaveBeenCalledWith( + 'Stake11111111111111111111111111111111111111', + expect.objectContaining({ + encoding: 'jsonParsed', + filters: [ + { + memcmp: { + offset: 44n, + bytes: String(WALLET) as Base58EncodedBytes, + encoding: 'base58', + }, + }, + ], + }), + ); + + expect(accounts).toEqual([VALIDATOR_STAKE_ACC, OTHER_STAKE_ACC]); + }); + + it('getStakeAccounts filters by validator ID when provided', async () => { + const helper = createStakeHelper(mockRuntime); + + const accounts = await helper.getStakeAccounts(WALLET, VALIDATOR_ID); + + expect(mockRpc.getProgramAccounts).toHaveBeenCalled(); + expect(accounts).toEqual([VALIDATOR_STAKE_ACC]); + expect(accounts).toHaveLength(1); + }); + + it('getStakeAccounts works with Address type', async () => { + const helper = createStakeHelper(mockRuntime); + const validatorAddress = address(VALIDATOR_ID); + + const accounts = await helper.getStakeAccounts(WALLET, validatorAddress); + + expect(accounts).toEqual([VALIDATOR_STAKE_ACC]); + }); + + it('getStakeAccounts returns empty array when no matches', async () => { + mockProgramAccountsSend.mockResolvedValueOnce([OTHER_STAKE_ACC]); + const helper = createStakeHelper(mockRuntime); + + const accounts = await helper.getStakeAccounts(WALLET, VALIDATOR_ID); + + expect(accounts).toEqual([]); + }); +}); diff --git a/packages/client/src/features/stake.ts b/packages/client/src/features/stake.ts new file mode 100644 index 0000000..39aedab --- /dev/null +++ b/packages/client/src/features/stake.ts @@ -0,0 +1,541 @@ +import { getBase58Decoder } from '@solana/codecs-strings'; +import { + type Address, + address, + appendTransactionMessageInstructions, + type Base58EncodedBytes, + type Blockhash, + type Commitment, + createTransactionMessage, + createTransactionPlanExecutor, + generateKeyPairSigner, + getBase64EncodedWireTransaction, + isTransactionSendingSigner, + pipe, + type Slot, + setTransactionMessageFeePayer, + setTransactionMessageLifetimeUsingBlockhash, + signAndSendTransactionMessageWithSigners, + signature, + signTransactionMessageWithSigners, + singleTransactionPlan, + type TransactionPlan, + type TransactionSigner, + type TransactionVersion, +} from '@solana/kit'; +import { + getDeactivateInstruction, + getDelegateStakeInstruction, + getInitializeInstruction, + getWithdrawInstruction, +} from '@solana-program/stake'; +import { getCreateAccountInstruction } from '@solana-program/system'; + +import { lamportsMath } from '../numeric/lamports'; +import { createWalletTransactionSigner, isWalletSession, resolveSignerMode } from '../signers/walletTransactionSigner'; +import type { SolanaClientRuntime, WalletSession } from '../types'; + +type BlockhashLifetime = Readonly<{ + blockhash: Blockhash; + lastValidBlockHeight: bigint; +}>; + +type StakeAmount = bigint | number | string; + +type StakeAuthority = TransactionSigner | WalletSession; + +export type StakeAccount = { + pubkey: Address; + account: { + data: { + parsed: { + info: { + stake?: { + delegation?: { + voter: string; + stake: string; + activationEpoch: string; + deactivationEpoch: string; + }; + }; + meta?: { + rentExemptReserve: string; + authorized: { + staker: string; + withdrawer: string; + }; + lockup: { + unixTimestamp: number; + epoch: number; + custodian: string; + }; + }; + }; + }; + }; + lamports: bigint; + }; +}; + +type SignableStakeTransactionMessage = Parameters[0]; + +// Stake Program constants +const STAKE_PROGRAM_ID: Address = 'Stake11111111111111111111111111111111111111' as Address; +const SYSVAR_CLOCK: Address = 'SysvarC1ock11111111111111111111111111111111' as Address; +const SYSVAR_STAKE_HISTORY: Address = 'SysvarStakeHistory1111111111111111111111111' as Address; +const UNUSED_STAKE_CONFIG_ACC: Address = 'StakeConfig11111111111111111111111111111111' as Address; +const STAKE_STATE_LEN = 200; + +export type StakePrepareConfig = Readonly<{ + amount: StakeAmount; + authority: StakeAuthority; + commitment?: Commitment; + lifetime?: BlockhashLifetime; + transactionVersion?: TransactionVersion; + validatorId: Address | string; +}>; + +export type StakeSendOptions = Readonly<{ + abortSignal?: AbortSignal; + commitment?: Commitment; + maxRetries?: bigint | number; + minContextSlot?: Slot; + skipPreflight?: boolean; +}>; + +export type UnstakePrepareConfig = Readonly<{ + authority: StakeAuthority; + commitment?: Commitment; + lifetime?: BlockhashLifetime; + stakeAccount: Address | string; + transactionVersion?: TransactionVersion; +}>; + +export type UnstakeSendOptions = StakeSendOptions; + +export type WithdrawPrepareConfig = Readonly<{ + amount: StakeAmount; + authority: StakeAuthority; + commitment?: Commitment; + destination: Address | string; + lifetime?: BlockhashLifetime; + stakeAccount: Address | string; + transactionVersion?: TransactionVersion; +}>; + +export type WithdrawSendOptions = StakeSendOptions; + +type PreparedUnstake = Readonly<{ + commitment?: Commitment; + lifetime: BlockhashLifetime; + message: SignableStakeTransactionMessage; + mode: 'partial' | 'send'; + plan: TransactionPlan; + signer: TransactionSigner; +}>; + +type PreparedWithdraw = Readonly<{ + commitment?: Commitment; + lifetime: BlockhashLifetime; + message: SignableStakeTransactionMessage; + mode: 'partial' | 'send'; + plan: TransactionPlan; + signer: TransactionSigner; +}>; + +type PreparedStake = Readonly<{ + commitment?: Commitment; + lifetime: BlockhashLifetime; + message: SignableStakeTransactionMessage; + mode: 'partial' | 'send'; + signer: TransactionSigner; + plan?: TransactionPlan; + stakeAccount: TransactionSigner; +}>; + +function ensureAddress(value: Address | string): Address { + return typeof value === 'string' ? address(value) : value; +} + +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: StakeAuthority, + 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: StakeAmount): bigint { + return lamportsMath.fromLamports(input); +} + +export type StakeHelper = Readonly<{ + getStakeAccounts(wallet: Address | string, validatorId?: Address | string): Promise; + prepareStake(config: StakePrepareConfig): Promise; + prepareUnstake(config: UnstakePrepareConfig): Promise; + prepareWithdraw(config: WithdrawPrepareConfig): Promise; + sendPreparedStake(prepared: PreparedStake, options?: StakeSendOptions): Promise>; + sendPreparedUnstake(prepared: PreparedUnstake, options?: UnstakeSendOptions): Promise>; + sendPreparedWithdraw( + prepared: PreparedWithdraw, + options?: WithdrawSendOptions, + ): Promise>; + sendStake(config: StakePrepareConfig, options?: StakeSendOptions): Promise>; + sendUnstake(config: UnstakePrepareConfig, options?: UnstakeSendOptions): Promise>; + sendWithdraw(config: WithdrawPrepareConfig, options?: WithdrawSendOptions): Promise>; +}>; + +/** Creates helpers that build and submit native SOL staking transactions. */ +export function createStakeHelper(runtime: SolanaClientRuntime): StakeHelper { + async function prepareStake(config: StakePrepareConfig): Promise { + const commitment = config.commitment; + const lifetime = await resolveLifetime(runtime, commitment, config.lifetime); + const { signer, mode } = resolveSigner(config.authority, commitment); + const validatorAddress = ensureAddress(config.validatorId); + const amount = toLamportAmount(config.amount); + + // Get rent exemption for stake account + const rentExempt = await runtime.rpc.getMinimumBalanceForRentExemption(BigInt(STAKE_STATE_LEN)).send(); + + const totalLamports = rentExempt + amount; + + // Generate a new stake account + const stakeAccount = await generateKeyPairSigner(); + + // Build instructions + const createIx = getCreateAccountInstruction({ + payer: signer, + newAccount: stakeAccount, + lamports: totalLamports, + space: BigInt(STAKE_STATE_LEN), + programAddress: STAKE_PROGRAM_ID, + }); + + const initializeIx = getInitializeInstruction({ + stake: stakeAccount.address, + arg0: { + staker: signer.address, + withdrawer: signer.address, + }, + arg1: { + unixTimestamp: 0n, + epoch: 0n, + custodian: signer.address, + }, + }); + + const delegateIx = getDelegateStakeInstruction({ + stake: stakeAccount.address, + vote: validatorAddress, + stakeHistory: SYSVAR_STAKE_HISTORY, + unused: UNUSED_STAKE_CONFIG_ACC, + stakeAuthority: signer, + }); + + const message = pipe( + createTransactionMessage({ version: config.transactionVersion ?? 0 }), + (m) => setTransactionMessageFeePayer(signer.address, m), + (m) => setTransactionMessageLifetimeUsingBlockhash(lifetime, m), + (m) => appendTransactionMessageInstructions([createIx, initializeIx, delegateIx], m), + ); + + return { + commitment, + lifetime, + message, + mode, + signer, + stakeAccount, + plan: singleTransactionPlan(message), + }; + } + + async function sendPreparedStake( + prepared: PreparedStake, + options: StakeSendOptions = {}, + ): 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 SignableStakeTransactionMessage, { + 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 sendStake( + config: StakePrepareConfig, + options?: StakeSendOptions, + ): Promise> { + const prepared = await prepareStake(config); + return await sendPreparedStake(prepared, options); + } + + async function prepareUnstake(config: UnstakePrepareConfig): Promise { + const commitment = config.commitment; + const lifetime = await resolveLifetime(runtime, commitment, config.lifetime); + const { signer, mode } = resolveSigner(config.authority, commitment); + const stakeAccountAddress = ensureAddress(config.stakeAccount); + + const deactivateIx = getDeactivateInstruction({ + stake: stakeAccountAddress, + clockSysvar: SYSVAR_CLOCK, + stakeAuthority: signer, + }); + + const message = pipe( + createTransactionMessage({ version: config.transactionVersion ?? 0 }), + (m) => setTransactionMessageFeePayer(signer.address, m), + (m) => setTransactionMessageLifetimeUsingBlockhash(lifetime, m), + (m) => appendTransactionMessageInstructions([deactivateIx], m), + ); + + return { + commitment, + lifetime, + message, + mode, + signer, + plan: singleTransactionPlan(message), + }; + } + + async function sendPreparedUnstake( + prepared: PreparedUnstake, + options: UnstakeSendOptions = {}, + ): 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 SignableStakeTransactionMessage, { + 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); + if (!latestSignature) { + throw new Error('Failed to resolve transaction signature.'); + } + return latestSignature; + } + + async function sendUnstake( + config: UnstakePrepareConfig, + options?: UnstakeSendOptions, + ): Promise> { + const prepared = await prepareUnstake(config); + return await sendPreparedUnstake(prepared, options); + } + + async function prepareWithdraw(config: WithdrawPrepareConfig): Promise { + const commitment = config.commitment; + const lifetime = await resolveLifetime(runtime, commitment, config.lifetime); + const { signer, mode } = resolveSigner(config.authority, commitment); + const stakeAccountAddress = ensureAddress(config.stakeAccount); + const destinationAddress = ensureAddress(config.destination); + const amount = toLamportAmount(config.amount); + + const withdrawIx = getWithdrawInstruction({ + stake: stakeAccountAddress, + recipient: destinationAddress, + clockSysvar: SYSVAR_CLOCK, + stakeHistory: SYSVAR_STAKE_HISTORY, + withdrawAuthority: signer, + args: amount, + }); + + const message = pipe( + createTransactionMessage({ version: config.transactionVersion ?? 0 }), + (m) => setTransactionMessageFeePayer(signer.address, m), + (m) => setTransactionMessageLifetimeUsingBlockhash(lifetime, m), + (m) => appendTransactionMessageInstructions([withdrawIx], m), + ); + + return { + commitment, + lifetime, + message, + mode, + signer, + plan: singleTransactionPlan(message), + }; + } + + async function sendPreparedWithdraw( + prepared: PreparedWithdraw, + options: WithdrawSendOptions = {}, + ): 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 SignableStakeTransactionMessage, { + 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); + if (!latestSignature) { + throw new Error('Failed to resolve transaction signature.'); + } + return latestSignature; + } + + async function sendWithdraw( + config: WithdrawPrepareConfig, + options?: WithdrawSendOptions, + ): Promise> { + const prepared = await prepareWithdraw(config); + return await sendPreparedWithdraw(prepared, options); + } + + async function getStakeAccounts(wallet: Address | string, validatorId?: Address | string): Promise { + const walletAddress = typeof wallet === 'string' ? wallet : String(wallet); + + const accounts = await runtime.rpc + .getProgramAccounts(STAKE_PROGRAM_ID, { + encoding: 'jsonParsed', + filters: [ + { + memcmp: { + offset: 44n, + bytes: walletAddress as Base58EncodedBytes, + encoding: 'base58', + }, + }, + ], + }) + .send(); + + if (!validatorId) { + return accounts as StakeAccount[]; + } + + const validatorIdStr = typeof validatorId === 'string' ? validatorId : String(validatorId); + return accounts.filter((acc) => { + const data = acc.account?.data; + if (data && 'parsed' in data) { + const info = data.parsed?.info as { stake?: { delegation?: { voter?: string } } } | undefined; + return info?.stake?.delegation?.voter === validatorIdStr; + } + return false; + }) as StakeAccount[]; + } + + return { + getStakeAccounts, + prepareStake, + prepareUnstake, + prepareWithdraw, + sendPreparedStake, + sendPreparedUnstake, + sendPreparedWithdraw, + sendStake, + sendUnstake, + sendWithdraw, + }; +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 2b39394..677932e 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -27,6 +27,14 @@ export { type SplTransferControllerConfig, type SplTransferInput, } from './controllers/splTransferController'; +export { + createStakeController, + type StakeController, + type StakeControllerConfig, + type StakeInput, + type UnstakeInput, + type WithdrawInput, +} from './controllers/stakeController'; export { createSolTransferHelper, type SolTransferHelper, @@ -40,6 +48,17 @@ export { type SplTokenHelperConfig, type SplTransferPrepareConfig, } from './features/spl'; +export { + createStakeHelper, + type StakeAccount, + type StakeHelper, + type StakePrepareConfig, + type StakeSendOptions, + type UnstakePrepareConfig, + type UnstakeSendOptions, + type WithdrawPrepareConfig, + type WithdrawSendOptions, +} from './features/stake'; export { createTransactionHelper, createTransactionRecipe, diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index 8685a7a..8eee39c 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -12,6 +12,7 @@ import type { TransactionWithLastValidBlockHeight } from '@solana/transaction-co import type { StoreApi } from 'zustand/vanilla'; 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 { SolanaRpcClient } from './rpc/createSolanaRpcClient'; import type { PrepareTransactionMessage, PrepareTransactionOptions } from './transactions/prepareTransaction'; @@ -295,6 +296,7 @@ export type ClientWatchers = Readonly<{ export type ClientHelpers = Readonly<{ solTransfer: SolTransferHelper; splToken(config: SplTokenHelperConfig): SplTokenHelper; + stake: StakeHelper; transaction: TransactionHelper; prepareTransaction( config: PrepareTransactionOptions, @@ -320,6 +322,7 @@ export type SolanaClient = Readonly<{ splToken(config: SplTokenHelperConfig): SplTokenHelper; SplToken(config: SplTokenHelperConfig): SplTokenHelper; SplHelper(config: SplTokenHelperConfig): SplTokenHelper; + stake: StakeHelper; transaction: TransactionHelper; prepareTransaction: ClientHelpers['prepareTransaction']; }>; diff --git a/packages/react-hooks/src/hooks.stake.test.tsx b/packages/react-hooks/src/hooks.stake.test.tsx new file mode 100644 index 0000000..2f3eba7 --- /dev/null +++ b/packages/react-hooks/src/hooks.stake.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest'; + +import { createAddress, createWalletSession } from '../test/fixtures'; +import { act, renderHookWithClient, waitFor } from '../test/utils'; + +import { useStake } from './hooks'; + +describe('useStake hook', () => { + it('delegates staking to the helper and tracks status', async () => { + const session = createWalletSession(); + const validatorId = createAddress(100); + const { client, result } = renderHookWithClient(() => useStake(validatorId), { + clientOptions: { + state: { + wallet: { + connectorId: session.connector.id, + session, + status: 'connected', + }, + }, + }, + }); + + expect(result.current.helper).toBe(client.stake); + expect(result.current.status).toBe('idle'); + expect(result.current.signature).toBeNull(); + expect(result.current.validatorId).toBe(String(validatorId)); + + await act(async () => { + const signature = await result.current.stake({ amount: 1_000_000_000n }); + expect(signature).toBeDefined(); + }); + + expect(client.stake.sendStake).toHaveBeenCalledWith( + { amount: 1_000_000_000n, authority: session, validatorId: String(validatorId) }, + undefined, + ); + expect(result.current.status).toBe('success'); + expect(result.current.signature).not.toBeNull(); + expect(result.current.isStaking).toBe(false); + + act(() => { + result.current.reset(); + }); + + expect(result.current.signature).toBeNull(); + expect(result.current.status).toBe('idle'); + }); + + it('throws when attempting to stake without an authority', async () => { + const validatorId = createAddress(101); + const { result } = renderHookWithClient(() => useStake(validatorId)); + await expect( + act(async () => { + await result.current.stake({ amount: 1_000_000_000n }); + }), + ).rejects.toThrowError('Connect a wallet or supply an `authority` before staking SOL.'); + }); + + it('surfaces helper errors and preserves them until reset', async () => { + const session = createWalletSession(); + const validatorId = createAddress(102); + const failure = new Error('stake failed'); + const { client, result } = renderHookWithClient(() => useStake(validatorId), { + clientOptions: { + state: { + wallet: { + connectorId: session.connector.id, + session, + status: 'connected', + }, + }, + }, + }); + + client.stake.sendStake.mockRejectedValueOnce(failure); + + await act(async () => { + await expect(result.current.stake({ amount: 2_000_000_000n })).rejects.toThrowError(failure); + }); + + await waitFor(() => { + expect(result.current.status).toBe('error'); + }); + expect(result.current.error).toBe(failure); + + act(() => { + result.current.reset(); + }); + + expect(result.current.status).toBe('idle'); + expect(result.current.error).toBeNull(); + }); + + it('properly normalizes validator ID', async () => { + const session = createWalletSession(); + const validatorId = createAddress(103); + const { result } = renderHookWithClient(() => useStake(validatorId), { + clientOptions: { + state: { + wallet: { + connectorId: session.connector.id, + session, + status: 'connected', + }, + }, + }, + }); + + expect(result.current.validatorId).toBe(String(validatorId)); + }); + + it('tracks isStaking state during transaction', async () => { + const session = createWalletSession(); + const validatorId = createAddress(104); + const { result } = renderHookWithClient(() => useStake(validatorId), { + clientOptions: { + state: { + wallet: { + connectorId: session.connector.id, + session, + status: 'connected', + }, + }, + }, + }); + + expect(result.current.isStaking).toBe(false); + + const stakePromise = act(async () => { + await result.current.stake({ amount: 500_000_000n }); + }); + + // During the stake operation, isStaking should be true + await waitFor(() => { + expect(result.current.isStaking).toBe(true); + }); + + await stakePromise; + + // After completion, isStaking should be false + expect(result.current.isStaking).toBe(false); + }); +}); diff --git a/packages/react-hooks/src/hooks.ts b/packages/react-hooks/src/hooks.ts index 8cac26f..414c048 100644 --- a/packages/react-hooks/src/hooks.ts +++ b/packages/react-hooks/src/hooks.ts @@ -9,6 +9,7 @@ import { createInitialAsyncState, createSolTransferController, createSplTransferController, + createStakeController, createTransactionPoolController, deriveConfirmationStatus, type LatestBlockhashCache, @@ -24,6 +25,10 @@ import { type SplTokenHelperConfig, type SplTransferController, type SplTransferInput, + type StakeAccount, + type StakeHelper, + type StakeInput, + type StakeSendOptions, type TransactionHelper, type TransactionInstructionInput, type TransactionInstructionList, @@ -36,8 +41,12 @@ import { type TransactionPrepared, type TransactionSendOptions, toAddress, + type UnstakeInput, + type UnstakeSendOptions, type WalletSession, type WalletStatus, + type WithdrawInput, + type WithdrawSendOptions, } from '@solana/client'; import type { Commitment, Lamports, Signature } from '@solana/kit'; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; @@ -310,6 +319,135 @@ export function useSolTransfer(): Readonly<{ }; } +type StakeSignature = UnwrapPromise>; +type UnstakeSignature = UnwrapPromise>; +type WithdrawSignature = UnwrapPromise>; + +/** + * Convenience wrapper around the stake helper that tracks status and signature for native SOL staking. + * Allows staking SOL to a validator and returns transaction details. + */ +export function useStake(validatorId: AddressLike): Readonly<{ + error: unknown; + getStakeAccounts(wallet: AddressLike, validatorIdFilter?: AddressLike): Promise; + helper: StakeHelper; + isStaking: boolean; + isUnstaking: boolean; + isWithdrawing: boolean; + reset(): void; + resetUnstake(): void; + resetWithdraw(): void; + stake(config: Omit, options?: StakeSendOptions): Promise; + unstake(config: Omit, options?: UnstakeSendOptions): Promise; + withdraw(config: Omit, options?: WithdrawSendOptions): Promise; + signature: StakeSignature | null; + unstakeSignature: UnstakeSignature | null; + withdrawSignature: WithdrawSignature | null; + status: AsyncState['status']; + unstakeStatus: AsyncState['status']; + withdrawStatus: AsyncState['status']; + unstakeError: unknown; + withdrawError: unknown; + validatorId: string; +}> { + const client = useSolanaClient(); + const session = useWalletSession(); + const helper = client.stake; + const sessionRef = useRef(session); + const normalizedValidatorId = useMemo(() => String(validatorId), [validatorId]); + + useEffect(() => { + sessionRef.current = session; + }, [session]); + + const controller = useMemo( + () => + createStakeController({ + authorityProvider: () => sessionRef.current, + helper, + }), + [helper], + ); + + const state = useSyncExternalStore>( + controller.subscribe, + controller.getState, + controller.getState, + ); + + const unstakeState = useSyncExternalStore>( + controller.subscribeUnstake, + controller.getUnstakeState, + controller.getUnstakeState, + ); + + const withdrawState = useSyncExternalStore>( + controller.subscribeWithdraw, + controller.getWithdrawState, + controller.getWithdrawState, + ); + + const stake = useCallback( + (config: Omit, options?: StakeSendOptions) => + controller.stake({ ...config, validatorId: normalizedValidatorId }, options), + [controller, normalizedValidatorId], + ); + + const unstake = useCallback( + (config: Omit, options?: UnstakeSendOptions) => + controller.unstake({ ...config }, options), + [controller], + ); + + const withdraw = useCallback( + (config: Omit, options?: WithdrawSendOptions) => + controller.withdraw({ ...config }, options), + [controller], + ); + + const getStakeAccounts = useCallback( + async (wallet: AddressLike, validatorIdFilter?: AddressLike) => { + if (!helper.getStakeAccounts) { + throw new Error( + 'getStakeAccounts is not available. Make sure you have the latest version of @solana/client package.', + ); + } + const walletAddr = typeof wallet === 'string' ? wallet : String(wallet); + const filterAddr = validatorIdFilter + ? typeof validatorIdFilter === 'string' + ? validatorIdFilter + : String(validatorIdFilter) + : undefined; + return helper.getStakeAccounts(walletAddr, filterAddr); + }, + [helper], + ); + + return { + error: state.error ?? null, + getStakeAccounts, + helper, + isStaking: state.status === 'loading', + isUnstaking: unstakeState.status === 'loading', + isWithdrawing: withdrawState.status === 'loading', + reset: controller.reset, + resetUnstake: controller.resetUnstake, + resetWithdraw: controller.resetWithdraw, + stake, + unstake, + withdraw, + signature: state.data ?? null, + unstakeSignature: unstakeState.data ?? null, + withdrawSignature: withdrawState.data ?? null, + status: state.status, + unstakeStatus: unstakeState.status, + withdrawStatus: withdrawState.status, + unstakeError: unstakeState.error ?? null, + withdrawError: withdrawState.error ?? null, + validatorId: normalizedValidatorId, + }; +} + type SplTokenBalanceResult = SplTokenBalance; type SplTransferSignature = UnwrapPromise>; type UseSplTokenOptions = Readonly<{ diff --git a/packages/react-hooks/src/index.ts b/packages/react-hooks/src/index.ts index ec6c3f6..2939af9 100644 --- a/packages/react-hooks/src/index.ts +++ b/packages/react-hooks/src/index.ts @@ -1,5 +1,6 @@ 'use client'; +export type { StakeAccount } from '@solana/client'; export type { SolanaClientProviderProps, UseSolanaClientParameters, @@ -52,6 +53,7 @@ export { useSignatureStatus, useSolTransfer, useSplToken, + useStake, useTransactionPool, useWaitForSignature, useWallet, diff --git a/packages/react-hooks/test/mocks.ts b/packages/react-hooks/test/mocks.ts index be08ad9..391c5c6 100644 --- a/packages/react-hooks/test/mocks.ts +++ b/packages/react-hooks/test/mocks.ts @@ -8,10 +8,10 @@ import { createClientStore, createInitialClientState, type SolanaClient, - type SolanaClientConfig, type SolTransferHelper, type SplTokenHelper, type SplTokenHelperConfig, + type StakeHelper, type TransactionHelper, type WalletConnector, type WalletRegistry, @@ -39,6 +39,10 @@ type MockedTransactionHelper = { [K in keyof TransactionHelper]: MockedFunction; }; +type MockedStakeHelper = { + [K in keyof StakeHelper]: MockedFunction; +}; + type CreateMockSplTokenHelper = (config: SplTokenHelperConfig) => MockedSplTokenHelper; export type MockSolanaClient = SolanaClient & { @@ -46,6 +50,7 @@ export type MockSolanaClient = SolanaClient & { helpers: ClientHelpers & { solTransfer: MockedSolTransferHelper; splToken: MockedFunction; + stake: MockedStakeHelper; transaction: MockedTransactionHelper; }; solTransfer: MockedSolTransferHelper; @@ -53,6 +58,7 @@ export type MockSolanaClient = SolanaClient & { splToken: MockedFunction; SplToken: MockedFunction; SplHelper: MockedFunction; + stake: MockedStakeHelper; transaction: MockedTransactionHelper; watchers: MockedWatchers; }; @@ -64,6 +70,7 @@ export type MockSolanaClientOptions = Readonly<{ createSplTokenHelper?: CreateMockSplTokenHelper; runtime?: Partial; solTransfer?: Partial; + stake?: Partial; state?: Partial; store?: ClientStore; transaction?: Partial; @@ -244,6 +251,23 @@ function createDefaultTransactionHelper(): MockedTransactionHelper { }; } +function createDefaultStakeHelper(): MockedStakeHelper { + return { + prepareStake: vi.fn(async () => ({ + commitment: 'confirmed', + lifetime: { blockhash: 'mock-blockhash', lastValidBlockHeight: 0n }, + message: {} as unknown, + mode: 'send', + signer: { address: 'mock' } as unknown as TransactionSigner, + stakeAccount: { address: 'mock-stake-account' } as unknown as TransactionSigner, + })), + sendPreparedStake: vi.fn( + async () => 'MockPreparedStakeSignature111111111111' as Signature, + ), + sendStake: vi.fn(async () => 'MockStakeSignature1111111111111111111111' as Signature), + }; +} + function createDefaultConnectors(connectors: readonly WalletConnector[] = []): WalletRegistry { return { all: connectors, @@ -300,6 +324,11 @@ export function createMockSolanaClient(options: MockSolanaClientOptions = {}): M ...(options.transaction ?? {}), }; + const stakeHelper: MockedStakeHelper = { + ...createDefaultStakeHelper(), + ...(options.stake ?? {}), + }; + const connectors = createDefaultConnectors(options.connectors); const config = normaliseConfig(options.config); @@ -312,6 +341,7 @@ export function createMockSolanaClient(options: MockSolanaClientOptions = {}): M const helpers = { solTransfer: solTransferHelper, splToken: splTokenFn, + stake: stakeHelper, transaction: transactionHelper, } as MockSolanaClient['helpers']; @@ -329,6 +359,7 @@ export function createMockSolanaClient(options: MockSolanaClientOptions = {}): M splToken: splTokenFn, SplToken: splTokenFn, SplHelper: splTokenFn, + stake: stakeHelper, transaction: transactionHelper, } as MockSolanaClient; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d6700c..34cc5fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -250,6 +250,9 @@ importers: '@solana-program/compute-budget': specifier: catalog:solana version: 0.9.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))) + '@solana-program/stake': + specifier: ^0.5.0 + version: 0.5.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))) '@solana-program/system': specifier: catalog:solana version: 0.9.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))) @@ -1134,6 +1137,11 @@ packages: peerDependencies: '@solana/kit': ^3.0 + '@solana-program/stake@0.5.0': + resolution: {integrity: sha512-G3G1kcyTDTqcDEUqJkKyPfHAGh6AociXnDu4dZ87LprWeV3qZ26tReiOu3HN7inf2wCyJ32BWJyxoKNFVL9C8w==} + peerDependencies: + '@solana/kit': ^5.0 + '@solana-program/system@0.9.0': resolution: {integrity: sha512-yu+i0SZ+c+0E9Cy+btoMiCbxRnP/FLQuv/Ba8l2klZApAiOX1Ja/2IGkctFV36fglsI7PwD9czkSkHm8og+QeA==} peerDependencies: @@ -3874,6 +3882,10 @@ snapshots: dependencies: '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/stake@0.5.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': + dependencies: + '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)) + '@solana-program/system@0.9.0(@solana/kit@5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))': dependencies: '@solana/kit': 5.0.0(fastestsmallesttextencoderdecoder@1.0.22)(typescript@5.9.3)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))