Skip to main content

solana_cli/
spend_utils.rs

1use {
2    crate::{
3        checks::{check_account_for_balance_with_commitment, get_fee_for_messages},
4        cli::CliError,
5        compute_budget::{UpdateComputeUnitLimitResult, simulate_and_update_compute_unit_limit},
6        stake, vote,
7    },
8    clap::ArgMatches,
9    solana_clap_utils::{
10        compute_budget::ComputeUnitLimit, input_parsers::lamports_of_sol, offline::SIGN_ONLY_ARG,
11    },
12    solana_cli_output::display::build_balance_message,
13    solana_commitment_config::CommitmentConfig,
14    solana_hash::Hash,
15    solana_message::{Message, VersionedMessage},
16    solana_pubkey::Pubkey,
17    solana_rpc_client::nonblocking::rpc_client::RpcClient,
18};
19
20#[derive(Debug, PartialEq, Eq, Clone, Copy)]
21pub enum SpendAmount {
22    All,
23    Available,
24    Some(u64),
25    RentExempt,
26    AllForAccountCreation { create_account_min_balance: u64 },
27}
28
29impl Default for SpendAmount {
30    fn default() -> Self {
31        Self::Some(u64::default())
32    }
33}
34
35impl SpendAmount {
36    pub fn new(amount: Option<u64>, sign_only: bool) -> Result<Self, CliError> {
37        match amount {
38            Some(lamports) => Ok(Self::Some(lamports)),
39            None if !sign_only => Ok(Self::All),
40            _ => Err(CliError::BadParameter(
41                "ALL amount not supported for sign-only operations".to_string(),
42            )),
43        }
44    }
45
46    pub fn new_from_matches(matches: &ArgMatches<'_>, name: &str) -> Result<Self, CliError> {
47        let sign_only = matches.is_present(SIGN_ONLY_ARG.name);
48        let amount = lamports_of_sol(matches, name);
49        if amount.is_some() {
50            return SpendAmount::new(amount, sign_only);
51        }
52        match matches.value_of(name).unwrap_or("ALL") {
53            "ALL" if !sign_only => Ok(SpendAmount::All),
54            "AVAILABLE" if !sign_only => Ok(SpendAmount::Available),
55            _ => Err(CliError::BadParameter(
56                "Only specific amounts are supported for sign-only operations".to_string(),
57            )),
58        }
59    }
60}
61
62struct SpendAndFee {
63    spend: u64,
64    fee: u64,
65}
66
67pub async fn resolve_spend_tx_and_check_account_balance<F>(
68    rpc_client: &RpcClient,
69    sign_only: bool,
70    amount: SpendAmount,
71    blockhash: &Hash,
72    from_pubkey: &Pubkey,
73    compute_unit_limit: ComputeUnitLimit,
74    build_message: F,
75    commitment: CommitmentConfig,
76) -> Result<(Message, u64), CliError>
77where
78    F: Fn(u64) -> Message,
79{
80    resolve_spend_tx_and_check_account_balances(
81        rpc_client,
82        sign_only,
83        amount,
84        blockhash,
85        from_pubkey,
86        from_pubkey,
87        compute_unit_limit,
88        build_message,
89        commitment,
90    )
91    .await
92}
93
94pub async fn resolve_spend_tx_and_check_account_balances<F>(
95    rpc_client: &RpcClient,
96    sign_only: bool,
97    amount: SpendAmount,
98    blockhash: &Hash,
99    from_pubkey: &Pubkey,
100    fee_pubkey: &Pubkey,
101    compute_unit_limit: ComputeUnitLimit,
102    build_message: F,
103    commitment: CommitmentConfig,
104) -> Result<(Message, u64), CliError>
105where
106    F: Fn(u64) -> Message,
107{
108    if sign_only {
109        let (message, SpendAndFee { spend, fee: _ }) = resolve_spend_message(
110            rpc_client,
111            amount,
112            None,
113            0,
114            from_pubkey,
115            fee_pubkey,
116            0,
117            compute_unit_limit,
118            build_message,
119        )
120        .await?;
121        Ok((message, spend))
122    } else {
123        let account = rpc_client
124            .get_account_with_commitment(from_pubkey, commitment)
125            .await?
126            .value
127            .unwrap_or_default();
128        let mut from_balance = account.lamports;
129        let from_rent_exempt_minimum =
130            if amount == SpendAmount::RentExempt || amount == SpendAmount::Available {
131                rpc_client
132                    .get_minimum_balance_for_rent_exemption(account.data.len())
133                    .await?
134            } else {
135                0
136            };
137        if amount == SpendAmount::RentExempt && account.owner == solana_sdk_ids::vote::id() {
138            // On top of the rent-exempt minimum, the vote program also reserves the rewards that
139            // are pending distribution to the account's stake delegators.
140            let pending_delegator_rewards =
141                vote::get_pending_delegator_rewards(&account, from_pubkey)?;
142            from_balance = from_balance.saturating_sub(pending_delegator_rewards);
143        }
144        if amount == SpendAmount::Available && account.owner == solana_sdk_ids::stake::id() {
145            let state = stake::get_account_stake_state(
146                rpc_client,
147                from_pubkey,
148                account,
149                true,
150                None,
151                false,
152                None,
153            )
154            .await?;
155            let mut subtract_rent_exempt_minimum = false;
156            if let Some(active_stake) = state.active_stake {
157                from_balance = from_balance.saturating_sub(active_stake);
158                subtract_rent_exempt_minimum = true;
159            }
160            if let Some(activating_stake) = state.activating_stake {
161                from_balance = from_balance.saturating_sub(activating_stake);
162                subtract_rent_exempt_minimum = true;
163            }
164            if subtract_rent_exempt_minimum {
165                from_balance = from_balance.saturating_sub(from_rent_exempt_minimum);
166            }
167        }
168        let (message, SpendAndFee { spend, fee }) = resolve_spend_message(
169            rpc_client,
170            amount,
171            Some(blockhash),
172            from_balance,
173            from_pubkey,
174            fee_pubkey,
175            from_rent_exempt_minimum,
176            compute_unit_limit,
177            build_message,
178        )
179        .await?;
180        if from_pubkey == fee_pubkey {
181            if from_balance == 0 || from_balance < spend.saturating_add(fee) {
182                return Err(CliError::InsufficientFundsForSpendAndFee(
183                    build_balance_message(spend, false, false),
184                    build_balance_message(fee, false, false),
185                    *from_pubkey,
186                ));
187            }
188        } else {
189            if from_balance < spend {
190                return Err(CliError::InsufficientFundsForSpend(
191                    build_balance_message(spend, false, false),
192                    *from_pubkey,
193                ));
194            }
195            if !check_account_for_balance_with_commitment(rpc_client, fee_pubkey, fee, commitment)
196                .await?
197            {
198                return Err(CliError::InsufficientFundsForFee(
199                    build_balance_message(fee, false, false),
200                    *fee_pubkey,
201                ));
202            }
203        }
204        Ok((message, spend))
205    }
206}
207
208async fn resolve_spend_message<F>(
209    rpc_client: &RpcClient,
210    amount: SpendAmount,
211    blockhash: Option<&Hash>,
212    from_account_transferable_balance: u64,
213    from_pubkey: &Pubkey,
214    fee_pubkey: &Pubkey,
215    from_rent_exempt_minimum: u64,
216    compute_unit_limit: ComputeUnitLimit,
217    build_message: F,
218) -> Result<(Message, SpendAndFee), CliError>
219where
220    F: Fn(u64) -> Message,
221{
222    let (fee, compute_unit_info) = match blockhash {
223        Some(blockhash) => {
224            // If the from account is the same as the fee payer, it's impossible
225            // to give a correct amount for the simulation with `SpendAmount::All`
226            // or `SpendAmount::RentExempt`.
227            // To know how much to transfer, we need to know the transaction fee,
228            // but the transaction fee is dependent on the amount of compute
229            // units used, which requires simulation.
230            // To get around this limitation, we simulate against an amount of
231            // `0`, since there are few situations in which `SpendAmount` can
232            // be `All` or `RentExempt` *and also* the from account is the fee
233            // payer.
234            let lamports = if from_pubkey == fee_pubkey {
235                match amount {
236                    SpendAmount::Some(lamports) => lamports,
237                    SpendAmount::AllForAccountCreation {
238                        create_account_min_balance,
239                    } => create_account_min_balance,
240                    SpendAmount::All | SpendAmount::Available | SpendAmount::RentExempt => 0,
241                }
242            } else {
243                match amount {
244                    SpendAmount::Some(lamports) => lamports,
245                    SpendAmount::AllForAccountCreation { .. }
246                    | SpendAmount::All
247                    | SpendAmount::Available => from_account_transferable_balance,
248                    SpendAmount::RentExempt => {
249                        from_account_transferable_balance.saturating_sub(from_rent_exempt_minimum)
250                    }
251                }
252            };
253            let mut dummy_message = build_message(lamports);
254
255            dummy_message.recent_blockhash = *blockhash;
256            let compute_unit_info =
257                if let UpdateComputeUnitLimitResult::UpdatedInstructionIndex(ix_index) =
258                    simulate_and_update_compute_unit_limit(
259                        &compute_unit_limit,
260                        rpc_client,
261                        &mut dummy_message,
262                    )
263                    .await?
264                {
265                    Some((ix_index, dummy_message.instructions[ix_index].data.clone()))
266                } else {
267                    None
268                };
269            (
270                get_fee_for_messages(rpc_client, &[&VersionedMessage::Legacy(dummy_message)])
271                    .await?,
272                compute_unit_info,
273            )
274        }
275        None => (0, None), // Offline, cannot calculate fee
276    };
277
278    let (mut message, spend_and_fee) = match amount {
279        SpendAmount::Some(lamports) => (
280            build_message(lamports),
281            SpendAndFee {
282                spend: lamports,
283                fee,
284            },
285        ),
286        SpendAmount::All | SpendAmount::AllForAccountCreation { .. } | SpendAmount::Available => {
287            let lamports = if from_pubkey == fee_pubkey {
288                from_account_transferable_balance.saturating_sub(fee)
289            } else {
290                from_account_transferable_balance
291            };
292            (
293                build_message(lamports),
294                SpendAndFee {
295                    spend: lamports,
296                    fee,
297                },
298            )
299        }
300        SpendAmount::RentExempt => {
301            let mut lamports = if from_pubkey == fee_pubkey {
302                from_account_transferable_balance.saturating_sub(fee)
303            } else {
304                from_account_transferable_balance
305            };
306            lamports = lamports.saturating_sub(from_rent_exempt_minimum);
307            (
308                build_message(lamports),
309                SpendAndFee {
310                    spend: lamports,
311                    fee,
312                },
313            )
314        }
315    };
316    // After build message, update with correct compute units
317    if let Some((ix_index, ix_data)) = compute_unit_info {
318        message.instructions[ix_index].data = ix_data;
319    }
320    Ok((message, spend_and_fee))
321}
322
323#[cfg(test)]
324mod tests {
325    use {
326        super::*,
327        serde_json::json,
328        solana_account::Account,
329        solana_account_decoder::{UiAccountEncoding, encode_ui_account},
330        solana_rpc_client_api::{
331            request::RpcRequest,
332            response::{Response, RpcResponseContext},
333        },
334        solana_vote_program::{
335            vote_instruction::withdraw,
336            vote_state::{VoteStateV4, VoteStateVersions},
337        },
338        std::collections::HashMap,
339    };
340
341    fn vote_account(lamports: u64, pending_delegator_rewards: u64) -> Account {
342        let vote_state = VoteStateV4 {
343            pending_delegator_rewards,
344            ..VoteStateV4::default()
345        };
346        let mut data = vec![0; VoteStateV4::size_of()];
347        VoteStateV4::serialize(&VoteStateVersions::new_v4(vote_state), &mut data).unwrap();
348        Account {
349            lamports,
350            data,
351            owner: solana_sdk_ids::vote::id(),
352            ..Account::default()
353        }
354    }
355
356    /// Resolves the amount that `withdraw-from-vote-account ALL` withdraws from
357    /// `vote_account`, with the fee paid by another account.
358    async fn resolve_rent_exempt_spend(vote_account: Account, rent_exempt_minimum: u64) -> u64 {
359        let vote_account_pubkey = Pubkey::new_unique();
360        let account_response = json!(Response {
361            context: RpcResponseContext {
362                slot: 1,
363                api_version: None
364            },
365            value: json!(encode_ui_account(
366                &vote_account_pubkey,
367                &vote_account,
368                UiAccountEncoding::Base64,
369                None,
370                None,
371            )),
372        });
373        let mut mocks = HashMap::new();
374        mocks.insert(RpcRequest::GetAccountInfo, account_response);
375        mocks.insert(
376            RpcRequest::GetMinimumBalanceForRentExemption,
377            json!(rent_exempt_minimum),
378        );
379        let rpc_client = RpcClient::new_mock_with_mocks("".to_string(), mocks);
380
381        let withdraw_authority = Pubkey::new_unique();
382        let destination_pubkey = Pubkey::new_unique();
383        let fee_payer = Pubkey::new_unique();
384        let (_message, spend) = resolve_spend_tx_and_check_account_balances(
385            &rpc_client,
386            false,
387            SpendAmount::RentExempt,
388            &Hash::default(),
389            &vote_account_pubkey,
390            &fee_payer,
391            ComputeUnitLimit::Default,
392            |lamports| {
393                Message::new(
394                    &[withdraw(
395                        &vote_account_pubkey,
396                        &withdraw_authority,
397                        lamports,
398                        &destination_pubkey,
399                    )],
400                    Some(&fee_payer),
401                )
402            },
403            CommitmentConfig::processed(),
404        )
405        .await
406        .unwrap();
407        spend
408    }
409
410    #[tokio::test]
411    async fn test_rent_exempt_spend_reserves_pending_delegator_rewards() {
412        const RENT_EXEMPT_MINIMUM: u64 = 27_000_000;
413        const PENDING_DELEGATOR_REWARDS: u64 = 5_000_000;
414        const WITHDRAWABLE: u64 = 1_000_000;
415        const RESERVED_BALANCE: u64 = RENT_EXEMPT_MINIMUM + PENDING_DELEGATOR_REWARDS;
416        const BALANCE: u64 = RESERVED_BALANCE + WITHDRAWABLE;
417        const BALANCE_WITHOUT_PENDING_REWARDS: u64 = RENT_EXEMPT_MINIMUM + WITHDRAWABLE;
418
419        // Without pending rewards, everything in excess of the rent-exempt
420        // minimum is withdrawable.
421        assert_eq!(
422            resolve_rent_exempt_spend(
423                vote_account(BALANCE_WITHOUT_PENDING_REWARDS, 0),
424                RENT_EXEMPT_MINIMUM,
425            )
426            .await,
427            WITHDRAWABLE
428        );
429
430        // Pending delegator rewards are reserved as well, matching the balance
431        // the vote program requires the account to retain.
432        assert_eq!(
433            resolve_rent_exempt_spend(
434                vote_account(BALANCE, PENDING_DELEGATOR_REWARDS),
435                RENT_EXEMPT_MINIMUM,
436            )
437            .await,
438            WITHDRAWABLE
439        );
440
441        // Nothing is withdrawable when the balance only covers the reserves.
442        assert_eq!(
443            resolve_rent_exempt_spend(
444                vote_account(RESERVED_BALANCE, PENDING_DELEGATOR_REWARDS),
445                RENT_EXEMPT_MINIMUM,
446            )
447            .await,
448            0
449        );
450    }
451}