1use crate::App;
2use anyhow::{anyhow, Context, Result};
3use futures::TryStreamExt;
4use penumbra_sdk_asset::{asset, Value, STAKING_TOKEN_ASSET_ID};
5use penumbra_sdk_keys::FullViewingKey;
6use penumbra_sdk_num::Amount;
7use penumbra_sdk_proto::view::v1::{AssetsRequest, GasPricesRequest};
8use penumbra_sdk_view::ViewClient;
9use penumbra_sdk_wallet::plan::Planner;
10use rand_core::OsRng;
11use std::{collections::HashMap, io::Write};
12use termion::input::TermRead;
13
14fn read_fvk() -> Result<FullViewingKey> {
15 print!("Enter FVK: ");
16 std::io::stdout().flush()?;
17 let fvk_string: String = std::io::stdin().lock().read_line()?.unwrap_or_default();
18
19 fvk_string
20 .parse::<FullViewingKey>()
21 .map_err(|_| anyhow::anyhow!("The provided string is not a valid FullViewingKey."))
22}
23
24fn parse_range(s: &str) -> Result<std::ops::Range<u32>> {
25 let parts: Vec<&str> = s.split("..").collect();
26 if parts.len() != 2 {
27 return Err(anyhow!("Invalid range format. Expected format: start..end"));
28 }
29
30 let start = parts[0]
31 .parse::<u32>()
32 .context("Invalid start value in range")?;
33 let end = parts[1]
34 .parse::<u32>()
35 .context("Invalid end value in range")?;
36
37 if start >= end {
38 return Err(anyhow!("Invalid range: start must be less than end"));
39 }
40
41 Ok(start..end)
42}
43
44#[derive(Debug, clap::Parser)]
45pub enum MigrateCmd {
46 #[clap(name = "balance")]
53 Balance,
54 #[clap(name = "subaccount-balance")]
60 SubaccountBalance {
61 #[clap(long, required = true, value_parser = parse_range, name = "subaccount_index_range")]
67 from_range: std::ops::Range<u32>,
68 #[clap(long)]
70 plan_only: bool,
71 },
72}
73
74impl MigrateCmd {
75 #[tracing::instrument(skip(self, app))]
76 pub async fn exec(&self, app: &mut App) -> Result<()> {
77 let gas_prices = app
78 .view
79 .as_mut()
80 .context("view service must be initialized")?
81 .gas_prices(GasPricesRequest {})
82 .await?
83 .into_inner()
84 .gas_prices
85 .expect("gas prices must be available")
86 .try_into()?;
87
88 match self {
89 MigrateCmd::Balance => {
90 let source_fvk = app.config.full_viewing_key.clone();
91
92 let dest_fvk = read_fvk()?;
93
94 let mut planner = Planner::new(OsRng);
95
96 planner
97 .set_gas_prices(gas_prices)
98 .set_fee_tier(Default::default());
99
100 let notes = app
102 .view
103 .as_mut()
104 .context("view service must be initialized")?
105 .unspent_notes_by_account_and_asset()
106 .await?;
107
108 let mut account_values: HashMap<(u32, asset::Id), Amount> = HashMap::new();
109
110 for (account, notes) in notes {
111 for notes in notes.into_values() {
112 for note in notes {
113 let position = note.position;
114 let note = note.note;
115 let value = note.value();
116 planner.spend(note, position);
117 *account_values.entry((account, value.asset_id)).or_default() +=
118 value.amount;
119 }
120 }
121 }
122
123 let (&(largest_account, _), _) = account_values
127 .iter()
128 .filter(|((_, asset), _)| *asset == *STAKING_TOKEN_ASSET_ID)
129 .max_by_key(|&(_, &amount)| amount)
130 .ok_or(anyhow!("no account with the ability to pay fees exists"))?;
131
132 planner.change_address(dest_fvk.payment_address(largest_account.into()).0);
134
135 for (&(account, asset_id), &amount) in &account_values {
137 if account == largest_account {
138 continue;
139 }
140 let (address, _) = dest_fvk.payment_address(account.into());
141 planner.output(Value { asset_id, amount }, address);
142 }
143
144 let memo = format!("Migrating balance from {} to {}", source_fvk, dest_fvk);
145 let plan = planner
146 .memo(memo)
147 .plan(
148 app.view
149 .as_mut()
150 .context("view service must be initialized")?,
151 Default::default(),
152 )
153 .await
154 .context("can't build send transaction")?;
155
156 if plan.actions.is_empty() {
157 anyhow::bail!("migration plan contained zero actions: is the source wallet already empty?");
158 }
159 app.build_and_submit_transaction(plan).await?;
160
161 Result::Ok(())
162 }
163 MigrateCmd::SubaccountBalance {
164 from_range,
165 plan_only,
166 } => {
167 let source_fvk = app.config.full_viewing_key.clone();
168
169 let dest_fvk = read_fvk()?;
171
172 let mut planner = Planner::new(OsRng);
173 planner
174 .set_gas_prices(gas_prices)
175 .set_fee_tier(Default::default());
176
177 let assets_response = app
179 .view
180 .as_mut()
181 .context("view service must be initialized")?
182 .assets(AssetsRequest {
183 filtered: false,
184 include_specific_denominations: vec![],
185 include_lp_nfts: true,
186 include_delegation_tokens: true,
187 include_unbonding_tokens: true,
188 include_proposal_nfts: false,
189 include_voting_receipt_tokens: false,
190 })
191 .await?;
192
193 let mut asset_cache = penumbra_sdk_asset::asset::Cache::default();
195 let assets_stream = assets_response.into_inner();
196 let assets = assets_stream
197 .try_collect::<Vec<_>>()
198 .await
199 .context("failed to collect assets")?;
200 for asset_response in assets {
201 if let Some(denom) = asset_response.denom_metadata {
202 let metadata =
203 denom.try_into().context("failed to parse asset metadata")?;
204 asset_cache.extend(std::iter::once(metadata));
205 }
206 }
207
208 let all_notes = app
210 .view
211 .as_mut()
212 .context("view service must be initialized")?
213 .unspent_notes_by_account_and_asset()
214 .await?;
215
216 let mut subaccount_values: HashMap<(u32, asset::Id), Amount> = HashMap::new();
218
219 for (account, notes_by_asset) in all_notes {
221 if from_range.contains(&account) {
222 for notes in notes_by_asset.into_values() {
223 for note in notes {
224 let position = note.position;
225 let note = note.note;
226 let value = note.value();
227 planner.spend(note, position);
228 *subaccount_values
229 .entry((account, value.asset_id))
230 .or_default() += value.amount;
231 }
232 }
233 }
234 }
235
236 if subaccount_values.is_empty() {
237 anyhow::bail!("no notes found in the specified subaccount range");
238 }
239
240 let (&(fee_account, _), _) = subaccount_values
242 .iter()
243 .filter(|((_, asset), _)| *asset == *STAKING_TOKEN_ASSET_ID)
244 .max_by_key(|&(_, &amount)| amount)
245 .ok_or(anyhow!(
246 "no subaccount in the range has the ability to pay fees"
247 ))?;
248
249 planner.change_address(dest_fvk.payment_address(fee_account.into()).0);
251
252 for (&(account, asset_id), &amount) in &subaccount_values {
254 if amount == Amount::zero() {
256 continue;
257 }
258
259 if account == fee_account && asset_id == *STAKING_TOKEN_ASSET_ID {
261 continue;
262 }
263
264 let (dest_address, _) = dest_fvk.payment_address(account.into());
265 planner.output(Value { asset_id, amount }, dest_address);
266 }
267
268 let memo = format!(
269 "Migrating subaccounts {}..{} from {} to {}",
270 from_range.start, from_range.end, source_fvk, dest_fvk
271 );
272
273 let plan = planner
274 .memo(memo)
275 .plan(
276 app.view
277 .as_mut()
278 .context("view service must be initialized")?,
279 Default::default(),
280 )
281 .await
282 .context("can't build migration transaction")?;
283
284 if plan.actions.is_empty() {
285 anyhow::bail!("migration plan contained zero actions: are the source subaccounts already empty?");
286 }
287
288 println!("\n=== Migration Summary ===");
290 println!("Source wallet: {}", source_fvk);
291 println!("Destination wallet: {}", dest_fvk);
292 println!(
293 "Subaccounts: {} through {} (inclusive)",
294 from_range.start, from_range.end
295 );
296
297 let mut asset_summary: HashMap<asset::Id, Amount> = HashMap::new();
299 for ((_, asset_id), amount) in &subaccount_values {
300 *asset_summary.entry(*asset_id).or_default() += *amount;
301 }
302
303 println!("\nAssets to migrate:");
305 let mut total_outputs = 0;
306 for (asset_id, total_amount) in &asset_summary {
307 if *total_amount > Amount::zero() {
308 let value = penumbra_sdk_asset::Value {
309 asset_id: *asset_id,
310 amount: *total_amount,
311 };
312 println!(" • {}", value.format(&asset_cache));
313 total_outputs += 1;
314 }
315 }
316
317 println!("Total outputs: {}", total_outputs);
318
319 println!("\nSubaccounts with balances:");
321 let mut accounts_with_balance: Vec<u32> = subaccount_values
322 .keys()
323 .map(|(account, _)| *account)
324 .collect::<std::collections::HashSet<_>>()
325 .into_iter()
326 .collect();
327 accounts_with_balance.sort();
328
329 for account in &accounts_with_balance {
330 println!(" • Subaccount {}", account);
331 }
332
333 println!("\nFee-paying subaccount: {}", fee_account);
334 println!("Total distinct assets: {}", asset_summary.len());
335 println!("========================\n");
336
337 if *plan_only {
338 println!("{}", serde_json::to_string_pretty(&plan)?);
339 } else {
340 print!("Send transaction? (Y/N): ");
342 std::io::stdout().flush()?;
343
344 let response: String = std::io::stdin().lock().read_line()?.unwrap_or_default();
345 let trimmed = response.trim().to_lowercase();
346
347 if trimmed == "y" || trimmed == "yes" {
348 app.build_and_submit_transaction(plan).await?;
349 } else {
350 println!("Transaction cancelled.");
351 }
352 }
353
354 Result::Ok(())
355 }
356 }
357 }
358}