pcli/command/
migrate.rs

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    /// Migrate your entire balance to another wallet.
47    ///
48    /// All assets from all accounts in the source wallet will be sent to the destination wallet.
49    /// A FullViewingKey must be provided for the destination wallet.
50    /// All funds will be deposited in the account 0 of the destination wallet,
51    /// minus any gas prices for the migration transaction.
52    #[clap(name = "balance")]
53    Balance,
54    /// Migrate balances from specified subaccounts to a destination wallet.
55    ///
56    /// All assets from the specified source subaccounts will be sent to the destination wallet.
57    /// A FullViewingKey must be provided for the destination wallet.
58    /// Gas fees will be paid from the source subaccount with the most fee token.
59    #[clap(name = "subaccount-balance")]
60    SubaccountBalance {
61        /// Range of source subaccount indices to migrate from (e.g., 0..17)
62        ///
63        /// The range is inclusive of the `start` value, and exclusive of the `end` value,
64        /// such that `1..4` will migrate subaccounts 1, 2, & 3, but not 4. Therefore
65        /// to migrate only subaccount 4, use `4..5`.
66        #[clap(long, required = true, value_parser = parse_range, name = "subaccount_index_range")]
67        from_range: std::ops::Range<u32>,
68        /// Only print the transaction plan without executing it (for threshold signing)
69        #[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                // Return all unspent notes from the view service
101                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                // We'll use the account with the most amount of the fee token to pay fees.
124                //
125                // If this fails, then it won't be possible to migrate.
126                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                // Set this account to be the change address.
133                planner.change_address(dest_fvk.payment_address(largest_account.into()).0);
134
135                // Create explicit outputs for the other addresses.
136                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                // Read destination FVK from stdin
170                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                // Get asset cache for human-readable denominations
178                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                // Build asset cache from the response
194                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                // Return all unspent notes from the view service
209                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                // Track values per (subaccount, asset) for fee calculation
217                let mut subaccount_values: HashMap<(u32, asset::Id), Amount> = HashMap::new();
218
219                // Filter and spend notes only from subaccounts in the specified range
220                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                // Find the subaccount with the most fee token to pay fees
241                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                // Set the change address to the destination's corresponding subaccount
250                planner.change_address(dest_fvk.payment_address(fee_account.into()).0);
251
252                // Create outputs for all assets to their corresponding destination subaccounts
253                for (&(account, asset_id), &amount) in &subaccount_values {
254                    // Skip empty values
255                    if amount == Amount::zero() {
256                        continue;
257                    }
258
259                    // For the fee account, the change will handle the remaining balance
260                    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                // Print migration summary
289                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                // Calculate total assets across all subaccounts
298                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                // Show assets being migrated with human-readable denominations
304                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                // Show which subaccounts contain assets
320                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                    // Ask for confirmation
341                    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}