11use crate :: App ;
22use anyhow:: { anyhow, Context , Result } ;
3+ use futures:: TryStreamExt ;
34use penumbra_sdk_asset:: { asset, Value , STAKING_TOKEN_ASSET_ID } ;
45use penumbra_sdk_keys:: FullViewingKey ;
56use penumbra_sdk_num:: Amount ;
6- use penumbra_sdk_proto:: view:: v1:: GasPricesRequest ;
7+ use penumbra_sdk_proto:: view:: v1:: { AssetsRequest , GasPricesRequest } ;
78use penumbra_sdk_view:: ViewClient ;
89use penumbra_sdk_wallet:: plan:: Planner ;
910use 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 ) ]
2445pub 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
3574impl 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 ! ( "\n Assets 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 ! ( "\n Subaccounts 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 ! ( "\n Fee-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