1use crate::App;
2use anyhow::{anyhow, Context, Result};
3use penumbra_sdk_asset::{asset, Value, STAKING_TOKEN_ASSET_ID};
4use penumbra_sdk_keys::FullViewingKey;
5use penumbra_sdk_num::Amount;
6use penumbra_sdk_proto::view::v1::GasPricesRequest;
7use penumbra_sdk_view::ViewClient;
8use penumbra_sdk_wallet::plan::Planner;
9use rand_core::OsRng;
10use std::{collections::HashMap, io::Write};
11use termion::input::TermRead;
12
13fn read_fvk() -> Result<FullViewingKey> {
14 print!("Enter FVK: ");
15 std::io::stdout().flush()?;
16 let fvk_string: String = std::io::stdin().lock().read_line()?.unwrap_or_default();
17
18 fvk_string
19 .parse::<FullViewingKey>()
20 .map_err(|_| anyhow::anyhow!("The provided string is not a valid FullViewingKey."))
21}
22
23#[derive(Debug, clap::Parser)]
24pub enum MigrateCmd {
25 #[clap(name = "balance")]
32 Balance,
33}
34
35impl MigrateCmd {
36 #[tracing::instrument(skip(self, app))]
37 pub async fn exec(&self, app: &mut App) -> Result<()> {
38 let gas_prices = app
39 .view
40 .as_mut()
41 .context("view service must be initialized")?
42 .gas_prices(GasPricesRequest {})
43 .await?
44 .into_inner()
45 .gas_prices
46 .expect("gas prices must be available")
47 .try_into()?;
48
49 match self {
50 MigrateCmd::Balance => {
51 let source_fvk = app.config.full_viewing_key.clone();
52
53 let dest_fvk = read_fvk()?;
54
55 let mut planner = Planner::new(OsRng);
56
57 planner
58 .set_gas_prices(gas_prices)
59 .set_fee_tier(Default::default());
60
61 let notes = app
63 .view
64 .as_mut()
65 .context("view service must be initialized")?
66 .unspent_notes_by_account_and_asset()
67 .await?;
68
69 let mut account_values: HashMap<(u32, asset::Id), Amount> = HashMap::new();
70
71 for (account, notes) in notes {
72 for notes in notes.into_values() {
73 for note in notes {
74 let position = note.position;
75 let note = note.note;
76 let value = note.value();
77 planner.spend(note, position);
78 *account_values.entry((account, value.asset_id)).or_default() +=
79 value.amount;
80 }
81 }
82 }
83
84 let (&(largest_account, _), _) = account_values
88 .iter()
89 .filter(|((_, asset), _)| *asset == *STAKING_TOKEN_ASSET_ID)
90 .max_by_key(|&(_, &amount)| amount)
91 .ok_or(anyhow!("no account with the ability to pay fees exists"))?;
92
93 planner.change_address(dest_fvk.payment_address(largest_account.into()).0);
95
96 for (&(account, asset_id), &amount) in &account_values {
98 if account == largest_account {
99 continue;
100 }
101 let (address, _) = dest_fvk.payment_address(account.into());
102 planner.output(Value { asset_id, amount }, address);
103 }
104
105 let memo = format!("Migrating balance from {} to {}", source_fvk, dest_fvk);
106 let plan = planner
107 .memo(memo)
108 .plan(
109 app.view
110 .as_mut()
111 .context("view service must be initialized")?,
112 Default::default(),
113 )
114 .await
115 .context("can't build send transaction")?;
116
117 if plan.actions.is_empty() {
118 anyhow::bail!("migration plan contained zero actions: is the source wallet already empty?");
119 }
120 app.build_and_submit_transaction(plan).await?;
121
122 Result::Ok(())
123 }
124 }
125 }
126}