Skip to content

Commit 4cc7b05

Browse files
Erwan Orconorsch
andauthored
feat(pcli): subaccount migration (#5238)
## Describe your changes Adds a new subcommand `pcli migrate subaccount-balance` to complement the pre-existing `pcli migrate balance`. The goal is to provide capability to migrate subaccounts, rather than entire wallets wholesale. Accepts a `--from-range` argument, so that multiple subaccount indices can be specified for the construction of the transaction plan. ## Issue ticket number and link N/A --------- Co-authored-by: Conor Schaefer <conor@penumbralabs.xyz>
1 parent 54591b7 commit 4cc7b05

File tree

1 file changed

+233
-1
lines changed

1 file changed

+233
-1
lines changed

crates/bin/pcli/src/command/migrate.rs

Lines changed: 233 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
use crate::App;
22
use anyhow::{anyhow, Context, Result};
3+
use futures::TryStreamExt;
34
use penumbra_sdk_asset::{asset, Value, STAKING_TOKEN_ASSET_ID};
45
use penumbra_sdk_keys::FullViewingKey;
56
use penumbra_sdk_num::Amount;
6-
use penumbra_sdk_proto::view::v1::GasPricesRequest;
7+
use penumbra_sdk_proto::view::v1::{AssetsRequest, GasPricesRequest};
78
use penumbra_sdk_view::ViewClient;
89
use penumbra_sdk_wallet::plan::Planner;
910
use rand_core::OsRng;
@@ -20,6 +21,26 @@ fn read_fvk() -> Result<FullViewingKey> {
2021
.map_err(|_| anyhow::anyhow!("The provided string is not a valid FullViewingKey."))
2122
}
2223

24+
fn 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+
2344
#[derive(Debug, clap::Parser)]
2445
pub enum MigrateCmd {
2546
/// Migrate your entire balance to another wallet.
@@ -30,6 +51,24 @@ pub enum MigrateCmd {
3051
/// minus any gas prices for the migration transaction.
3152
#[clap(name = "balance")]
3253
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+
},
3372
}
3473

3574
impl MigrateCmd {
@@ -119,6 +158,199 @@ impl MigrateCmd {
119158
}
120159
app.build_and_submit_transaction(plan).await?;
121160

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+
122354
Result::Ok(())
123355
}
124356
}

0 commit comments

Comments
 (0)