penumbra_sdk_transaction/
action_list.rs

1use anyhow::Result;
2use std::collections::BTreeMap;
3
4use crate::plan::MemoPlan;
5use crate::{gas::GasCost, TransactionParameters};
6use crate::{ActionPlan, TransactionPlan};
7use penumbra_sdk_asset::{asset, Balance};
8use penumbra_sdk_fee::{Fee, FeeTier, Gas, GasPrices};
9use penumbra_sdk_keys::Address;
10use penumbra_sdk_num::Amount;
11use penumbra_sdk_shielded_pool::{fmd, OutputPlan};
12use rand_core::{CryptoRng, RngCore};
13
14/// A list of planned actions to be turned into a TransactionPlan.
15///
16/// A transaction is a bundle of actions plus auxiliary data (like a memo). A
17/// transaction plan is a bundle of action plans plus plans for the auxiliary
18/// data (like a memo plan).  The [`ActionList`] is just the list of actions,
19/// which is useful for building up a [`TransactionPlan`].
20#[derive(Debug, Default, Clone)]
21pub struct ActionList {
22    // A list of the user-specified outputs.
23    actions: Vec<ActionPlan>,
24    // These are tracked separately for convenience when adjusting change.
25    change_outputs: BTreeMap<asset::Id, OutputPlan>,
26    // The fee is tracked as part of the ActionList so it can be adjusted
27    // internally to handle special cases like swap claims.
28    fee: Fee,
29}
30
31impl ActionList {
32    /// Returns an immutable reference to a list of action plans.
33    pub fn actions(&self) -> &Vec<ActionPlan> {
34        &self.actions
35    }
36
37    /// Returns an immutable reference to a map of change outputs.
38    pub fn change_outputs(&self) -> &BTreeMap<asset::Id, OutputPlan> {
39        &self.change_outputs
40    }
41
42    /// Returns an immutable reference to the fee.
43    pub fn fee(&self) -> &Fee {
44        &self.fee
45    }
46
47    /// Returns true if the resulting transaction would require a memo.
48    pub fn requires_memo(&self) -> bool {
49        let has_change_outputs = !self.change_outputs.is_empty();
50        let has_other_outputs = self
51            .actions
52            .iter()
53            .any(|action| matches!(action, ActionPlan::Output(_)));
54
55        has_change_outputs || has_other_outputs
56    }
57
58    /// Convert this list of actions into a [`TransactionPlan`].
59    pub fn into_plan<R: RngCore + CryptoRng>(
60        self,
61        rng: R,
62        fmd_params: &fmd::Parameters,
63        mut transaction_parameters: TransactionParameters,
64        memo_plan: Option<MemoPlan>,
65    ) -> Result<TransactionPlan> {
66        transaction_parameters.fee = self.fee;
67
68        let mut plan = TransactionPlan {
69            actions: self
70                .actions
71                .into_iter()
72                .chain(self.change_outputs.into_values().map(Into::into))
73                .collect(),
74            transaction_parameters,
75            memo: memo_plan,
76            detection_data: None,
77        };
78        plan.populate_detection_data(rng, fmd_params.precision);
79
80        // Implement a canonical ordering to the actions within the transaction
81        // plan to reduce client distinguishability.
82        plan.sort_actions();
83
84        Ok(plan)
85    }
86
87    /// Push a new action onto this list.
88    pub fn push<A: Into<ActionPlan>>(&mut self, action: A) {
89        let plan = action.into();
90
91        // Special case: if the plan is a `SwapClaimPlan`, adjust the fee to include the
92        // prepaid fee contributed by the swap claim. This helps ensure that the value
93        // released by the swap claim is used to pay the fee, rather than generating change.
94        if let ActionPlan::SwapClaim(claim) = &plan {
95            let claim_fee = claim.swap_plaintext.claim_fee;
96            if self.fee.amount() == Amount::zero() {
97                // If the fee is currently zero, set it to the claim fee,
98                // regardless of fee token, i.e., set the fee token to match
99                // the swap claim.
100                self.fee = claim_fee;
101            } else if self.fee.asset_matches(&claim_fee) {
102                // Otherwise, if the fee token matches, accumulate the amount
103                // released by the swap claim into the fee, rather than letting it
104                // be handled as change.
105                self.fee.0.amount += claim_fee.amount();
106            } else {
107                // In this situation, the fee has been manually set to a
108                // different token than was released by the swap claim. So we
109                // can't accumulate the swap claim fee into it, and it will
110                // produce change instead.
111            }
112        }
113
114        self.actions.push(plan);
115    }
116
117    /// Compute the gas used by a transaction comprised of the actions in this list.
118    ///
119    /// Because Penumbra transactions have static gas costs, and gas use is linear in the actions,
120    /// this is an exact computation.
121    fn gas_cost(&self) -> Gas {
122        let mut gas = Gas::zero();
123        for action in &self.actions {
124            // TODO missing AddAssign
125            gas = gas + action.gas_cost();
126        }
127        for action in self.change_outputs.values() {
128            // TODO missing AddAssign
129            // TODO missing GasCost impl on OutputPlan
130            gas = gas + ActionPlan::from(action.clone()).gas_cost();
131        }
132
133        gas
134    }
135
136    /// Use the provided gas prices and fee tier to estimate the fee for
137    /// the transaction.
138    ///
139    /// While the gas cost can be computed exactly, the base fee can only be
140    /// estimated, because the actual base fee paid by the transaction will
141    /// depend on the gas prices at the time it's accepted on-chain.
142    fn compute_fee_estimate(&self, gas_prices: &GasPrices, fee_tier: &FeeTier) -> Fee {
143        let base_fee = gas_prices.fee(&self.gas_cost());
144        base_fee.apply_tier(*fee_tier)
145    }
146
147    /// Use the provided gas prices and fee tier to refresh the fee estimate for
148    /// the transaction.
149    ///
150    /// If the current fee estimate is too low, it will be increased. In that
151    /// case, change notes will be adjusted to cover the increase if possible.
152    pub fn refresh_fee_and_change<R: RngCore + CryptoRng>(
153        &mut self,
154        rng: R,
155        gas_prices: &GasPrices,
156        fee_tier: &FeeTier,
157        change_address: &Address,
158    ) {
159        // First, refresh the change outputs, to capture any surplus imbalance.
160        self.refresh_change(rng, &change_address);
161
162        // Next, recompute the fee estimate for the actions and change outputs.
163        let new_fee = self.compute_fee_estimate(gas_prices, fee_tier);
164
165        // Update the targeted fee with the new estimate.
166        if new_fee.asset_matches(&self.fee) {
167            // Take the max of the current fee and the new estimate. This ensures
168            // that if we already overpaid the fee for some reason, we don't lower it
169            // and cause the creation of unwanted change outputs.
170            self.fee.0.amount = std::cmp::max(self.fee.amount(), new_fee.amount());
171        } else {
172            // Otherwise, overwrite the previous fee with the new estimate.
173            self.fee = new_fee;
174        }
175
176        // Finally, adjust the change outputs to cover the fee increase if possible.
177        self.adjust_change_for_imbalance();
178    }
179
180    /// Return the balance of the actions in the list, without accounting for fees.
181    pub fn balance_without_fee(&self) -> Balance {
182        let mut balance = Balance::zero();
183
184        for action in &self.actions {
185            balance += action.balance();
186        }
187        for action in self.change_outputs.values() {
188            balance += action.balance();
189        }
190
191        balance
192    }
193
194    /// Return the balance of the actions in the list, minus the currently estimated fee
195    /// required to pay their gas costs.
196    pub fn balance_with_fee(&self) -> Balance {
197        self.balance_without_fee() - self.fee.0
198    }
199
200    /// Refresh the change notes used to store any surplus imbalance from the
201    /// actions in the list.
202    fn refresh_change<R: RngCore + CryptoRng>(&mut self, mut rng: R, change_address: &Address) {
203        self.change_outputs = BTreeMap::new();
204        // For each "provided" balance component, create a change note.
205        for value in self.balance_with_fee().provided() {
206            self.change_outputs.insert(
207                value.asset_id,
208                OutputPlan::new(&mut rng, value, change_address.clone()),
209            );
210        }
211    }
212
213    /// Attempt adjust existing change notes to repair imbalance:
214    ///
215    /// - cover required balance by decreasing change if possible
216    /// - cover surplus balance by increasing change if possible
217    fn adjust_change_for_imbalance(&mut self) {
218        // We need to grab the current balance upfront before doing modifications.
219        let balance = self.balance_with_fee();
220
221        // Sweep surplus balance into existing change notes.
222        for provided in balance.provided() {
223            self.change_outputs
224                .entry(provided.asset_id)
225                .and_modify(|e| {
226                    e.value.amount += provided.amount;
227                });
228        }
229
230        // Attempt to cover imbalance via existing change notes.
231        for required in balance.required() {
232            self.change_outputs
233                .entry(required.asset_id)
234                .and_modify(|e| {
235                    // It's important to use saturating_sub here because
236                    // our expectation is that we commonly won't have enough balance.
237                    e.value.amount = e.value.amount.saturating_sub(&required.amount);
238                });
239        }
240
241        // Remove any 0-value change notes we might have created.
242        self.change_outputs
243            .retain(|_, output| output.value.amount > Amount::zero());
244    }
245}