use crate::cli::output::{emit, progress, subfield, OutputMode, Render, TxOutputView};
use solana_client::rpc_client::RpcClient;
use solana_commitment_config::CommitmentConfig;
use solana_sdk::{
native_token::LAMPORTS_PER_SOL,
pubkey::Pubkey,
signature::{read_keypair_file, Signer},
transaction::Transaction,
};
use spherenet_stake_interface::{
instruction::{create_account, deactivate_stake, delegate_stake, withdraw as withdraw_ix},
state::{Authorized, Lockup, StakeStateV2},
};
use std::str::FromStr;
#[derive(serde::Serialize)]
pub struct StakeAccountCreatedView {
stake_account: String,
signature: String,
}
impl Render for StakeAccountCreatedView {
fn to_text(&self) -> String {
let mut out = String::from("✅ Stake account created\n");
out.push_str(&subfield("Stake Account", &self.stake_account));
out.push_str(&subfield("Signature", &self.signature));
out
}
}
#[allow(clippy::too_many_arguments)]
pub fn create(
rpc_url: &str,
stake_account_path: String,
amount: f64,
stake_authority: String,
withdraw_authority: String,
from_path: String,
payer_path: String,
mode: OutputMode,
) -> eyre::Result<()> {
let rpc_client =
RpcClient::new_with_commitment(rpc_url.to_string(), CommitmentConfig::confirmed());
let stake_account = read_keypair_file(&stake_account_path).map_err(|e| {
eyre::eyre!(
"Failed to read stake account keypair from {}: {}",
stake_account_path,
e
)
})?;
let from = read_keypair_file(&from_path)
.map_err(|e| eyre::eyre!("Failed to read from keypair from {}: {}", from_path, e))?;
let payer = read_keypair_file(&payer_path)
.map_err(|e| eyre::eyre!("Failed to read payer keypair from {}: {}", payer_path, e))?;
let staker = Pubkey::from_str(&stake_authority).map_err(|e| {
eyre::eyre!(
"Invalid stake authority pubkey '{}': {}",
stake_authority,
e
)
})?;
let withdrawer = Pubkey::from_str(&withdraw_authority).map_err(|e| {
eyre::eyre!(
"Invalid withdraw authority pubkey '{}': {}",
withdraw_authority,
e
)
})?;
let lamports = (amount * LAMPORTS_PER_SOL as f64) as u64;
let rent = rpc_client.get_minimum_balance_for_rent_exemption(StakeStateV2::size_of())?;
if lamports < rent {
return Err(eyre::eyre!(
"--amount {} SPHR is below the rent-exempt minimum of {:.9} SPHR for a stake account",
amount,
rent as f64 / LAMPORTS_PER_SOL as f64
));
}
let delegatable = lamports.saturating_sub(rent);
let authorized = Authorized { staker, withdrawer };
let lockup = Lockup::default();
progress("Creating stake account:");
progress(format!("Stake Account: {}", stake_account.pubkey()));
progress(format!(
"Funding: {:.9} SPHR ({} lamports)",
lamports as f64 / LAMPORTS_PER_SOL as f64,
lamports
));
progress(format!(
" Rent reserve: {:.9} SPHR",
rent as f64 / LAMPORTS_PER_SOL as f64
));
progress(format!(
" Delegatable: {:.9} SPHR (staked on delegate)",
delegatable as f64 / LAMPORTS_PER_SOL as f64
));
progress(format!("Stake Authority: {}", staker));
progress(format!("Withdraw Authority:{}", withdrawer));
progress(format!("Funder (from): {}", from.pubkey()));
progress(format!("Fee Payer: {}", payer.pubkey()));
let instructions = create_account(
&from.pubkey(),
&stake_account.pubkey(),
&authorized,
&lockup,
lamports,
);
let signers = crate::utils::run::dedupe_signers(&[&payer, &from, &stake_account]);
let mut transaction = Transaction::new_with_payer(&instructions, Some(&payer.pubkey()));
transaction.sign(&signers, rpc_client.get_latest_blockhash()?);
let signature = rpc_client.send_and_confirm_transaction(&transaction)?;
progress("Not delegated yet — run `stake delegate` to delegate to a vote account.");
emit(
&StakeAccountCreatedView {
stake_account: stake_account.pubkey().to_string(),
signature: signature.to_string(),
},
mode,
)
}
pub fn delegate(
rpc_url: &str,
stake_account: String,
vote_account: String,
stake_authority_path: String,
payer_path: String,
mode: OutputMode,
) -> eyre::Result<()> {
let rpc_client =
RpcClient::new_with_commitment(rpc_url.to_string(), CommitmentConfig::confirmed());
let stake_pubkey = Pubkey::from_str(&stake_account)
.map_err(|e| eyre::eyre!("Invalid stake account pubkey '{}': {}", stake_account, e))?;
let vote_pubkey = Pubkey::from_str(&vote_account)
.map_err(|e| eyre::eyre!("Invalid vote account pubkey '{}': {}", vote_account, e))?;
let stake_authority = read_keypair_file(&stake_authority_path).map_err(|e| {
eyre::eyre!(
"Failed to read stake authority keypair from {}: {}",
stake_authority_path,
e
)
})?;
let payer = read_keypair_file(&payer_path)
.map_err(|e| eyre::eyre!("Failed to read payer keypair from {}: {}", payer_path, e))?;
preflight_vote_account(&rpc_client, &vote_pubkey)?;
let (whitelist_entry, _bump) = crate::vw::run::derive_whitelist_entry(&vote_pubkey);
preflight_whitelisted(&rpc_client, &vote_pubkey, &whitelist_entry)?;
preflight_stake_account(&rpc_client, &stake_pubkey, &stake_authority.pubkey())?;
progress("Delegating stake:");
progress(format!("Stake Account: {}", stake_pubkey));
progress(format!("Vote Account: {}", vote_pubkey));
progress(format!("Whitelist Entry: {}", whitelist_entry));
progress(format!("Stake Authority: {}", stake_authority.pubkey()));
progress(format!("Fee Payer: {}", payer.pubkey()));
let instruction = delegate_stake(
&stake_pubkey,
&stake_authority.pubkey(),
&vote_pubkey,
&whitelist_entry,
);
let signers = crate::utils::run::dedupe_signers(&[&payer, &stake_authority]);
let mut transaction = Transaction::new_with_payer(&[instruction], Some(&payer.pubkey()));
transaction.sign(&signers, rpc_client.get_latest_blockhash()?);
let signature = rpc_client.send_and_confirm_transaction(&transaction)?;
emit(
&TxOutputView::Executed {
signature: signature.to_string(),
},
mode,
)
}
fn preflight_vote_account(rpc_client: &RpcClient, vote_pubkey: &Pubkey) -> eyre::Result<()> {
let vote_program = Pubkey::from(solana_vote_interface::program::id().to_bytes());
match rpc_client.get_account(vote_pubkey) {
Ok(account) if account.owner == vote_program => Ok(()),
Ok(account) => Err(eyre::eyre!(
"{} is not a vote account (owner: {})",
vote_pubkey,
account.owner
)),
Err(_) => Err(eyre::eyre!(
"Vote account {} not found — create it first (`vote create`)",
vote_pubkey
)),
}
}
fn preflight_whitelisted(
rpc_client: &RpcClient,
vote_pubkey: &Pubkey,
whitelist_entry: &Pubkey,
) -> eyre::Result<()> {
if rpc_client.get_account(whitelist_entry).is_err() {
return Err(eyre::eyre!(
"Vote account {} is not whitelisted (no entry at {}).\n\
Delegation will be rejected by the stake program. Run:\n \
spherenet-admin vw add {} --authority <whitelist-authority>",
vote_pubkey,
whitelist_entry,
vote_pubkey
));
}
Ok(())
}
fn preflight_stake_account(
rpc_client: &RpcClient,
stake_pubkey: &Pubkey,
staker: &Pubkey,
) -> eyre::Result<()> {
let stake_program = Pubkey::from(spherenet_stake_interface::program::id().to_bytes());
let account = rpc_client.get_account(stake_pubkey).map_err(|_| {
eyre::eyre!(
"Stake account {} not found — create it first (`stake create`)",
stake_pubkey
)
})?;
if account.owner != stake_program {
return Err(eyre::eyre!(
"{} is not a stake account (owner: {})",
stake_pubkey,
account.owner
));
}
let state: StakeStateV2 = bincode::deserialize(&account.data)
.map_err(|e| eyre::eyre!("Failed to deserialize stake account state: {}", e))?;
match state {
StakeStateV2::Initialized(meta) => {
let account_staker = Pubkey::from(meta.authorized.staker.to_bytes());
if account_staker != *staker {
return Err(eyre::eyre!(
"Provided stake authority {} does not match the account's staker {}",
staker,
account_staker
));
}
Ok(())
}
StakeStateV2::Stake(..) => Err(eyre::eyre!(
"Stake account {} is already delegated (run `stake show` to inspect)",
stake_pubkey
)),
StakeStateV2::Uninitialized => Err(eyre::eyre!(
"Stake account {} is uninitialized — create it with `stake create`",
stake_pubkey
)),
StakeStateV2::RewardsPool => Err(eyre::eyre!(
"{} is a rewards pool, not delegatable",
stake_pubkey
)),
}
}
pub fn deactivate(
rpc_url: &str,
stake_account: String,
stake_authority_path: String,
payer_path: String,
mode: OutputMode,
) -> eyre::Result<()> {
let rpc_client =
RpcClient::new_with_commitment(rpc_url.to_string(), CommitmentConfig::confirmed());
let stake_pubkey = Pubkey::from_str(&stake_account)
.map_err(|e| eyre::eyre!("Invalid stake account pubkey '{}': {}", stake_account, e))?;
let stake_authority = read_keypair_file(&stake_authority_path).map_err(|e| {
eyre::eyre!(
"Failed to read stake authority keypair from {}: {}",
stake_authority_path,
e
)
})?;
let payer = read_keypair_file(&payer_path)
.map_err(|e| eyre::eyre!("Failed to read payer keypair from {}: {}", payer_path, e))?;
preflight_deactivate(&rpc_client, &stake_pubkey, &stake_authority.pubkey())?;
progress("Deactivating stake:");
progress(format!("Stake Account: {}", stake_pubkey));
progress(format!("Stake Authority: {}", stake_authority.pubkey()));
progress(format!("Fee Payer: {}", payer.pubkey()));
let instruction = deactivate_stake(&stake_pubkey, &stake_authority.pubkey());
let signers = crate::utils::run::dedupe_signers(&[&payer, &stake_authority]);
let mut transaction = Transaction::new_with_payer(&[instruction], Some(&payer.pubkey()));
transaction.sign(&signers, rpc_client.get_latest_blockhash()?);
let signature = rpc_client.send_and_confirm_transaction(&transaction)?;
progress(
"Stake cools down over the rest of this epoch; withdraw with the withdraw \
authority once it is fully inactive (`stake show` to track).",
);
emit(
&TxOutputView::Executed {
signature: signature.to_string(),
},
mode,
)
}
fn preflight_deactivate(
rpc_client: &RpcClient,
stake_pubkey: &Pubkey,
staker: &Pubkey,
) -> eyre::Result<()> {
let stake_program = Pubkey::from(spherenet_stake_interface::program::id().to_bytes());
let account = rpc_client
.get_account(stake_pubkey)
.map_err(|_| eyre::eyre!("Stake account {} not found", stake_pubkey))?;
if account.owner != stake_program {
return Err(eyre::eyre!(
"{} is not a stake account (owner: {})",
stake_pubkey,
account.owner
));
}
let state: StakeStateV2 = bincode::deserialize(&account.data)
.map_err(|e| eyre::eyre!("Failed to deserialize stake account state: {}", e))?;
match state {
StakeStateV2::Stake(meta, _stake, _flags) => {
let account_staker = Pubkey::from(meta.authorized.staker.to_bytes());
if account_staker != *staker {
return Err(eyre::eyre!(
"Provided stake authority {} does not match the account's staker {}",
staker,
account_staker
));
}
Ok(())
}
StakeStateV2::Initialized(_) => Err(eyre::eyre!(
"Stake account {} is not delegated — nothing to deactivate",
stake_pubkey
)),
StakeStateV2::Uninitialized => Err(eyre::eyre!(
"Stake account {} is uninitialized",
stake_pubkey
)),
StakeStateV2::RewardsPool => Err(eyre::eyre!("{} is a rewards pool", stake_pubkey)),
}
}
#[allow(clippy::too_many_arguments)]
pub fn withdraw(
rpc_url: &str,
stake_account: String,
destination: String,
amount: Option<f64>,
all: bool,
withdraw_authority_path: String,
payer_path: String,
mode: OutputMode,
) -> eyre::Result<()> {
if amount.is_none() && !all {
return Err(eyre::eyre!("Provide either --amount <SPHR> or --all"));
}
let rpc_client =
RpcClient::new_with_commitment(rpc_url.to_string(), CommitmentConfig::confirmed());
let stake_pubkey = Pubkey::from_str(&stake_account)
.map_err(|e| eyre::eyre!("Invalid stake account pubkey '{}': {}", stake_account, e))?;
let destination = Pubkey::from_str(&destination)
.map_err(|e| eyre::eyre!("Invalid destination pubkey '{}': {}", destination, e))?;
let withdrawer = read_keypair_file(&withdraw_authority_path).map_err(|e| {
eyre::eyre!(
"Failed to read withdraw authority keypair from {}: {}",
withdraw_authority_path,
e
)
})?;
let payer = read_keypair_file(&payer_path)
.map_err(|e| eyre::eyre!("Failed to read payer keypair from {}: {}", payer_path, e))?;
let balance = preflight_withdraw(&rpc_client, &stake_pubkey, &withdrawer.pubkey())?;
let lamports = if all {
balance
} else {
(amount.unwrap() * LAMPORTS_PER_SOL as f64) as u64
};
if lamports == 0 {
return Err(eyre::eyre!("Nothing to withdraw (amount resolves to 0)"));
}
if lamports > balance {
return Err(eyre::eyre!(
"Requested {:.9} SPHR exceeds the account balance of {:.9} SPHR",
lamports as f64 / LAMPORTS_PER_SOL as f64,
balance as f64 / LAMPORTS_PER_SOL as f64
));
}
progress("Withdrawing from stake account:");
progress(format!("Stake Account: {}", stake_pubkey));
progress(format!("Destination: {}", destination));
progress(format!(
"Amount: {:.9} SPHR{}",
lamports as f64 / LAMPORTS_PER_SOL as f64,
if all {
" (entire balance — closes account)"
} else {
""
}
));
progress(format!("Withdraw Authority:{}", withdrawer.pubkey()));
progress(format!("Fee Payer: {}", payer.pubkey()));
let instruction = withdraw_ix(
&stake_pubkey,
&withdrawer.pubkey(),
&destination,
lamports,
None,
);
let signers = crate::utils::run::dedupe_signers(&[&payer, &withdrawer]);
let mut transaction = Transaction::new_with_payer(&[instruction], Some(&payer.pubkey()));
transaction.sign(&signers, rpc_client.get_latest_blockhash()?);
let signature = rpc_client.send_and_confirm_transaction(&transaction)?;
if all {
progress(format!("Stake account {} closed.", stake_pubkey));
}
emit(
&TxOutputView::Executed {
signature: signature.to_string(),
},
mode,
)
}
fn preflight_withdraw(
rpc_client: &RpcClient,
stake_pubkey: &Pubkey,
withdrawer: &Pubkey,
) -> eyre::Result<u64> {
let stake_program = Pubkey::from(spherenet_stake_interface::program::id().to_bytes());
let account = rpc_client
.get_account(stake_pubkey)
.map_err(|_| eyre::eyre!("Stake account {} not found", stake_pubkey))?;
if account.owner != stake_program {
return Err(eyre::eyre!(
"{} is not a stake account (owner: {})",
stake_pubkey,
account.owner
));
}
let state: StakeStateV2 = bincode::deserialize(&account.data)
.map_err(|e| eyre::eyre!("Failed to deserialize stake account state: {}", e))?;
let account_withdrawer = match &state {
StakeStateV2::Initialized(meta) => Pubkey::from(meta.authorized.withdrawer.to_bytes()),
StakeStateV2::Stake(meta, _, _) => Pubkey::from(meta.authorized.withdrawer.to_bytes()),
StakeStateV2::Uninitialized => {
return Err(eyre::eyre!(
"Stake account {} is uninitialized — nothing to withdraw",
stake_pubkey
))
}
StakeStateV2::RewardsPool => return Err(eyre::eyre!("{} is a rewards pool", stake_pubkey)),
};
if account_withdrawer != *withdrawer {
return Err(eyre::eyre!(
"Provided withdraw authority {} does not match the account's withdrawer {}",
withdrawer,
account_withdrawer
));
}
if let StakeStateV2::Stake(_, stake, _) = &state {
if stake.delegation.deactivation_epoch == u64::MAX {
progress(
"⚠️ WARNING: this stake account is still delegated and not deactivated. \
Only lamports above the effective stake (+ rent) are withdrawable. Run \
`stake deactivate` and wait for cooldown to withdraw the full balance.",
);
}
}
Ok(account.lamports)
}