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}