Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Custom RPC URL for Solana connection
# Replace with your actual RPC endpoint (e.g., from Helius, QuickNode, or your own node)
# If not set, defaults to the public RPC for the selected network
NEXT_PUBLIC_RPC_URL=

# Preferred validators API endpoint for optimized unstaking
# This API returns a prioritized list of validators based on available withdrawable liquidity
# If not set, defaults to:
# - Testnet: https://kobe.testnet.jito.network/api/v1/preferred_withdraw_validator_list
# - Mainnet: https://kobe.mainnet.jito.network/api/v1/preferred_withdraw_validator_list
# For local development or custom API endpoints, set this to your API URL
NEXT_PUBLIC_PREFERRED_VALIDATORS_API_URL=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ yarn-error.log*
# local env files
.env*
.env.*
!.env.example

# vercel
.vercel
180 changes: 107 additions & 73 deletions README.md

Large diffs are not rendered by default.

185 changes: 89 additions & 96 deletions components/UnstakeTab.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,32 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useConnection, useWallet } from '@solana/wallet-adapter-react';
import Button from './Button';
import { useAssistedUnstake, UnstakeParams } from '../hooks/useAssistedUnstake';
import { useManualUnstake } from '../hooks/useManualUnstake';
import { StakeMethod } from '../constants';
import { getAssociatedTokenAddressSync } from '@solana/spl-token';
import { JITO_MINT_ADDRESS } from '../constants';
import dynamic from 'next/dynamic';
import toast from 'react-hot-toast';
import { PublicKey } from '@solana/web3.js';
import React, { useState, useEffect, useCallback } from "react";
import { useConnection, useWallet } from "@solana/wallet-adapter-react";
import Button from "./Button";
import { useAssistedUnstake, UnstakeParams } from "../hooks/useAssistedUnstake";
import { useManualUnstake } from "../hooks/useManualUnstake";
import { StakeMethod } from "../constants";
import { getAssociatedTokenAddressSync } from "@solana/spl-token";
import { JITO_MINT_ADDRESS } from "../constants";
import dynamic from "next/dynamic";
import toast from "react-hot-toast";
import { PublicKey } from "@solana/web3.js";

const WalletMultiButton = dynamic(
() =>
import('@solana/wallet-adapter-react-ui').then(
import("@solana/wallet-adapter-react-ui").then(
(mod) => mod.WalletMultiButton,
),
{ ssr: false },
);

const UnstakeTab: React.FC = () => {
const [amount, setAmount] = useState<string>('');
const [unstakeMethod, setUnstakeMethod] = useState<StakeMethod>(StakeMethod.ASSISTED_UNSTAKE);
const [amount, setAmount] = useState<string>("");
const [unstakeMethod, setUnstakeMethod] = useState<StakeMethod>(
StakeMethod.ASSISTED_UNSTAKE,
);
const [useReserve, setUseReserve] = useState<boolean>(false);
const [voteAccountAddress, setVoteAccountAddress] = useState<string>('');
const [stakeReceiver, setStakeReceiver] = useState<string>('');
const [stakeReceiver, setStakeReceiver] = useState<string>("");
const [showAdvancedOptions, setShowAdvancedOptions] = useState<boolean>(true);

const wallet = useWallet();
const { connection } = useConnection();
const [jitoSolBalance, setJitoSolBalance] = useState<number | null>(null);
Expand All @@ -38,24 +39,26 @@ const UnstakeTab: React.FC = () => {
try {
const userPoolTokenAccount = getAssociatedTokenAddressSync(
JITO_MINT_ADDRESS,
wallet.publicKey
wallet.publicKey,
);

// Check if the account exists
const accountInfo = await connection.getAccountInfo(userPoolTokenAccount);
const accountInfo =
await connection.getAccountInfo(userPoolTokenAccount);

if (!accountInfo) {
setJitoSolBalance(0);
return;
}

// Get token account data
const tokenAccountInfo = await connection.getTokenAccountBalance(userPoolTokenAccount);
const tokenAccountInfo =
await connection.getTokenAccountBalance(userPoolTokenAccount);
const balance = tokenAccountInfo.value.uiAmount || 0;
setJitoSolBalance(balance);
} catch (error) {
console.error('Error fetching JitoSOL balance:', error);
toast.error('Failed to fetch JitoSOL balance');
console.error("Error fetching JitoSOL balance:", error);
toast.error("Failed to fetch JitoSOL balance");
setJitoSolBalance(0);
}
}
Expand All @@ -72,71 +75,69 @@ const UnstakeTab: React.FC = () => {

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();

if (!amount || !wallet.publicKey) return;

const amountValue = parseFloat(amount);
if (isNaN(amountValue) || amountValue <= 0) return;

let success = false;

try {
if (unstakeMethod === StakeMethod.ASSISTED_UNSTAKE) {
// Prepare additional parameters for assisted unstake
const params: UnstakeParams = {
useReserve: useReserve
useReserve: useReserve,
};

// Add vote account address if provided
if (!useReserve && showAdvancedOptions && voteAccountAddress) {
try {
params.voteAccountAddress = new PublicKey(voteAccountAddress);
} catch {
toast.error('Invalid vote account address');
return;
}
}


// Add stake receiver if provided
if (!useReserve && showAdvancedOptions && stakeReceiver) {
try {
params.stakeReceiver = new PublicKey(stakeReceiver);
} catch {
toast.error('Invalid stake receiver address');
toast.error("Invalid stake receiver address");
return;
}
}

// amount value is in JitoSOL -- not decimal adjusted
success = await assistedUnstake.unstake(amountValue, params);
} else {
// Manual unstake - uses preferred validators API
success = await manualUnstake.unstake(amountValue);
}

if (success) {
setAmount('');
setAmount("");
fetchBalance();
}
} catch (error) {
console.error('Error in unstake submission:', error);
toast.error('Unstaking failed. Please check console for details.');
console.error("Error in unstake submission:", error);
toast.error("Unstaking failed. Please check console for details.");
}
};

return (
<div className="w-full mx-auto p-2 sm:p-6 bg-white">
<h2 className="text-2xl font-bold mb-6 text-black">Unstake JitoSOL to SOL</h2>

<h2 className="text-2xl font-bold mb-6 text-black">
Unstake JitoSOL to SOL
</h2>

{!wallet.publicKey ? (
<div className="flex flex-col items-center justify-center py-6">
<p className="mb-4 text-gray-600">Connect your wallet to get started</p>
<p className="mb-4 text-gray-600">
Connect your wallet to get started
</p>
<WalletMultiButton />
</div>
) : (
<>
<div className="mb-6">
<div className="flex justify-between items-center mb-2">
<label htmlFor="amount" className="block text-sm font-medium text-gray-700">
<label
htmlFor="amount"
className="block text-sm font-medium text-gray-700"
>
Amount to Unstake
</label>
{jitoSolBalance !== null && (
Expand All @@ -161,7 +162,7 @@ const UnstakeTab: React.FC = () => {
className="w-full p-2 border text-black border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
</div>

<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Unstake Method
Expand All @@ -171,8 +172,8 @@ const UnstakeTab: React.FC = () => {
type="button"
className={`py-2 px-4 rounded-md ${
unstakeMethod === StakeMethod.ASSISTED_UNSTAKE
? 'bg-purple-600 text-white'
: 'bg-gray-200 text-gray-800'
? "bg-purple-600 text-white"
: "bg-gray-200 text-gray-800"
}`}
onClick={() => setUnstakeMethod(StakeMethod.ASSISTED_UNSTAKE)}
>
Expand All @@ -182,8 +183,8 @@ const UnstakeTab: React.FC = () => {
type="button"
className={`py-2 px-4 rounded-md ${
unstakeMethod === StakeMethod.MANUAL_UNSTAKE
? 'bg-purple-600 text-white'
: 'bg-gray-200 text-gray-800'
? "bg-purple-600 text-white"
: "bg-gray-200 text-gray-800"
}`}
onClick={() => setUnstakeMethod(StakeMethod.MANUAL_UNSTAKE)}
>
Expand All @@ -192,33 +193,36 @@ const UnstakeTab: React.FC = () => {
</div>
<p className="mt-2 text-sm text-gray-500">
{unstakeMethod === StakeMethod.ASSISTED_UNSTAKE
? 'Assisted unstaking uses the SPL stake pool library.'
: 'Manual unstaking constructs the transactions manually.'}
? "Assisted unstaking uses the SPL stake pool library."
: "Manual unstaking constructs the transactions manually."}
</p>
</div>

{unstakeMethod === StakeMethod.ASSISTED_UNSTAKE && (
<div className="mb-6">
<div className="flex items-center">
<input
id="useReserve"
type="checkbox"
checked={useReserve}
onChange={(e) => setUseReserve(e.target.checked)}
className="h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded"
/>
<label htmlFor="useReserve" className="ml-2 block text-sm text-gray-700">
Use reserve
</label>
</div>
<p className="mt-2 text-sm text-gray-500">
{useReserve
? 'Receive SOL immediately with a fee.'
: 'Receive SOL after the next epoch with no fee.'}
id="useReserve"
type="checkbox"
checked={useReserve}
onChange={(e) => setUseReserve(e.target.checked)}
className="h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded"
/>
<label
htmlFor="useReserve"
className="ml-2 block text-sm text-gray-700"
>
Use reserve
</label>
</div>
<p className="mt-2 text-sm text-gray-500">
{useReserve
? "Receive SOL immediately with a fee."
: "Receive SOL after the next epoch with no fee."}
</p>
</div>
)}

{/* Advanced options for assisted unstaking without reserve */}
{unstakeMethod === StakeMethod.ASSISTED_UNSTAKE && !useReserve && (
<div className="mb-6">
Expand All @@ -227,30 +231,18 @@ const UnstakeTab: React.FC = () => {
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
className="text-sm text-purple-600 hover:text-purple-800"
>
{showAdvancedOptions ? 'Hide advanced options' : 'Show advanced options'}
{showAdvancedOptions
? "Hide advanced options"
: "Show advanced options"}
</button>

{showAdvancedOptions && (
<div className="mt-4 space-y-4 border p-4 rounded-md border-gray-200">
<div>
<label htmlFor="voteAccountAddress" className="block text-sm font-medium text-gray-700 mb-1">
Vote Account Address (optional)
</label>
<input
type="text"
id="voteAccountAddress"
value={voteAccountAddress}
onChange={(e) => setVoteAccountAddress(e.target.value)}
placeholder="Specific validator to withdraw from"
className="w-full p-2 border text-black border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm"
/>
<p className="mt-1 text-xs text-gray-500">
Specify a validator to withdraw from. Leave empty for auto-selection.
</p>
</div>

<div>
<label htmlFor="stakeReceiver" className="block text-sm font-medium text-gray-700 mb-1">
<label
htmlFor="stakeReceiver"
className="block text-sm font-medium text-gray-700 mb-1"
>
Stake Receiver Address (optional)
</label>
<input
Expand All @@ -262,24 +254,25 @@ const UnstakeTab: React.FC = () => {
className="w-full p-2 border text-black border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 text-sm"
/>
<p className="mt-1 text-xs text-gray-500">
Existing stake account to receive unstaked SOL. Must be delegated to the same validator if specified above.
Existing stake account to receive unstaked SOL. Must be
delegated to the same validator if specified above.
</p>
</div>
</div>
)}
</div>
)}

<Button
type="button"
label="Unstake JitoSOL"
width="full"
onClick={handleSubmit}
loading={assistedUnstake.isLoading || manualUnstake.isLoading}
disabled={
!amount ||
parseFloat(amount) <= 0 ||
assistedUnstake.isLoading ||
!amount ||
parseFloat(amount) <= 0 ||
assistedUnstake.isLoading ||
manualUnstake.isLoading
}
/>
Expand All @@ -289,4 +282,4 @@ const UnstakeTab: React.FC = () => {
);
};

export default UnstakeTab;
export default UnstakeTab;
2 changes: 1 addition & 1 deletion components/WalletContextProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,4 @@ export const WalletContextProvider: FC<WalletContextProviderProps> = ({ children
);
};

export default WalletContextProvider;
export default WalletContextProvider;
Loading