penumbra_sdk_wallet/
plan.rs

1use std::collections::BTreeMap;
2
3use anyhow::Context;
4use decaf377::Fq;
5use rand_core::{CryptoRng, RngCore};
6use tracing::instrument;
7
8use penumbra_sdk_dex::swap_claim::SwapClaimPlan;
9use penumbra_sdk_keys::keys::AddressIndex;
10use penumbra_sdk_proto::view::v1::NotesRequest;
11use penumbra_sdk_transaction::{TransactionParameters, TransactionPlan};
12pub use penumbra_sdk_view::Planner;
13use penumbra_sdk_view::{SpendableNoteRecord, ViewClient};
14
15pub const SWEEP_COUNT: usize = 8;
16
17#[instrument(skip(view, rng))]
18pub async fn sweep<V, R>(view: &mut V, mut rng: R) -> anyhow::Result<Vec<TransactionPlan>>
19where
20    V: ViewClient,
21    R: RngCore + CryptoRng,
22{
23    let mut plans = Vec::new();
24
25    // First, find any un-claimed swaps and add `SwapClaim` plans for them.
26    plans.extend(claim_unclaimed_swaps(view, &mut rng).await?);
27
28    // Finally, sweep dust notes by spending them to their owner's address.
29    // This will consolidate small-value notes into larger ones.
30    plans.extend(sweep_notes(view, &mut rng).await?);
31
32    Ok(plans)
33}
34
35#[instrument(skip(view, rng))]
36async fn claim_unclaimed_swaps<V, R>(
37    view: &mut V,
38    mut rng: R,
39) -> anyhow::Result<Vec<TransactionPlan>>
40where
41    V: ViewClient,
42    R: RngCore + CryptoRng,
43{
44    let mut plans = Vec::new();
45    // fetch all transactions
46    // check if they contain Swap actions
47    // if they do, check if the associated notes are unspent
48    // if they are, decrypt the SwapCiphertext in the Swap action and construct a SwapClaim
49
50    let app_params = view.app_params().await?;
51    let epoch_duration = app_params.sct_params.epoch_duration;
52
53    let unclaimed_swaps = view.unclaimed_swaps().await?;
54
55    for swap in unclaimed_swaps {
56        // We found an unclaimed swap, so we can claim it.
57        let swap_plaintext = swap.swap;
58
59        let output_data = swap.output_data;
60
61        let mut plan = TransactionPlan {
62            transaction_parameters: TransactionParameters {
63                chain_id: app_params.clone().chain_id,
64                fee: swap_plaintext.claim_fee.clone(),
65                ..Default::default()
66            },
67            // The transaction doesn't need a memo, because it's to ourselves.
68            memo: None,
69            ..Default::default()
70        };
71
72        let action_plan = SwapClaimPlan {
73            swap_plaintext,
74            position: swap.position,
75            output_data,
76            epoch_duration,
77            proof_blinding_r: Fq::rand(&mut rng),
78            proof_blinding_s: Fq::rand(&mut rng),
79        };
80        plan.actions.push(action_plan.into());
81        plans.push(plan);
82    }
83
84    Ok(plans)
85}
86
87#[instrument(skip(view, rng))]
88async fn sweep_notes<V, R>(view: &mut V, mut rng: R) -> anyhow::Result<Vec<TransactionPlan>>
89where
90    V: ViewClient,
91    R: RngCore + CryptoRng,
92{
93    let gas_prices = view.gas_prices().await?;
94
95    let all_notes = view
96        .notes(NotesRequest {
97            ..Default::default()
98        })
99        .await?;
100
101    let mut notes_by_addr_and_denom: BTreeMap<AddressIndex, BTreeMap<_, Vec<SpendableNoteRecord>>> =
102        BTreeMap::new();
103
104    for record in all_notes {
105        notes_by_addr_and_denom
106            .entry(record.address_index)
107            .or_default()
108            .entry(record.note.asset_id())
109            .or_default()
110            .push(record);
111    }
112
113    let mut plans = Vec::new();
114
115    for (index, notes_by_denom) in notes_by_addr_and_denom {
116        tracing::info!(?index, "processing address");
117
118        for (asset_id, mut records) in notes_by_denom {
119            tracing::debug!(?asset_id, "processing asset");
120
121            // Sort notes by amount, ascending, so the biggest notes are at the end...
122            records.sort_by(|a, b| a.note.value().amount.cmp(&b.note.value().amount));
123            // ... so that when we use chunks_exact, we get SWEEP_COUNT sized
124            // chunks, ignoring the biggest notes in the remainder.
125            for group in records.chunks_exact(SWEEP_COUNT) {
126                let mut planner = Planner::new(&mut rng);
127                planner.set_gas_prices(gas_prices);
128
129                for record in group {
130                    planner.spend(record.note.clone(), record.position);
131                }
132
133                let plan = planner
134                    .plan(view, index)
135                    .await
136                    .context("can't build sweep transaction")?;
137
138                tracing::debug!(?plan);
139                plans.push(plan);
140            }
141        }
142    }
143
144    Ok(plans)
145}