zebra_rpc/methods/types/get_block_template/
zip317.rs

1//! The [ZIP-317 block production algorithm](https://zips.z.cash/zip-0317#block-production).
2//!
3//! This is recommended algorithm, so these calculations are not consensus-critical,
4//! or standardised across node implementations:
5//! > it is sufficient to use floating point arithmetic to calculate the argument to `floor`
6//! > when computing `size_target`, since there is no consensus requirement for this to be
7//! > exactly the same between implementations.
8
9use std::collections::{HashMap, HashSet};
10
11use rand::{
12    distributions::{Distribution, WeightedIndex},
13    prelude::thread_rng,
14};
15
16use zcash_keys::address::Address;
17
18use zebra_chain::{
19    amount::NegativeOrZero,
20    block::{Height, MAX_BLOCK_BYTES},
21    parameters::Network,
22    transaction::{self, zip317::BLOCK_UNPAID_ACTION_LIMIT, Transaction, VerifiedUnminedTx},
23};
24use zebra_consensus::MAX_BLOCK_SIGOPS;
25use zebra_node_services::mempool::TransactionDependencies;
26
27use crate::methods::types::transaction::TransactionTemplate;
28
29#[cfg(test)]
30mod tests;
31
32#[cfg(test)]
33use crate::methods::types::get_block_template::InBlockTxDependenciesDepth;
34
35use super::standard_coinbase_outputs;
36
37/// Used in the return type of [`select_mempool_transactions()`] for test compilations.
38#[cfg(test)]
39type SelectedMempoolTx = (InBlockTxDependenciesDepth, VerifiedUnminedTx);
40
41/// Used in the return type of [`select_mempool_transactions()`] for non-test compilations.
42#[cfg(not(test))]
43type SelectedMempoolTx = VerifiedUnminedTx;
44
45/// Selects mempool transactions for block production according to [ZIP-317],
46/// using a fake coinbase transaction and the mempool.
47///
48/// The fake coinbase transaction's serialized size and sigops must be at least as large
49/// as the real coinbase transaction. (The real coinbase transaction depends on the total
50/// fees from the transactions returned by this function.)
51///
52/// Returns selected transactions from `mempool_txs`.
53///
54/// [ZIP-317]: https://zips.z.cash/zip-0317#block-production
55pub fn select_mempool_transactions(
56    network: &Network,
57    next_block_height: Height,
58    miner_address: &Address,
59    mempool_txs: Vec<VerifiedUnminedTx>,
60    mempool_tx_deps: TransactionDependencies,
61    extra_coinbase_data: Vec<u8>,
62) -> Vec<SelectedMempoolTx> {
63    // Use a fake coinbase transaction to break the dependency between transaction
64    // selection, the miner fee, and the fee payment in the coinbase transaction.
65    let fake_coinbase_tx = fake_coinbase_transaction(
66        network,
67        next_block_height,
68        miner_address,
69        extra_coinbase_data,
70    );
71
72    let tx_dependencies = mempool_tx_deps.dependencies();
73    let (independent_mempool_txs, mut dependent_mempool_txs): (HashMap<_, _>, HashMap<_, _>) =
74        mempool_txs
75            .into_iter()
76            .map(|tx| (tx.transaction.id.mined_id(), tx))
77            .partition(|(tx_id, _tx)| !tx_dependencies.contains_key(tx_id));
78
79    // Setup the transaction lists.
80    let (mut conventional_fee_txs, mut low_fee_txs): (Vec<_>, Vec<_>) = independent_mempool_txs
81        .into_values()
82        .partition(VerifiedUnminedTx::pays_conventional_fee);
83
84    let mut selected_txs = Vec::new();
85
86    // Set up limit tracking
87    let mut remaining_block_bytes: usize = MAX_BLOCK_BYTES.try_into().expect("fits in memory");
88    let mut remaining_block_sigops = MAX_BLOCK_SIGOPS;
89    let mut remaining_block_unpaid_actions: u32 = BLOCK_UNPAID_ACTION_LIMIT;
90
91    // Adjust the limits based on the coinbase transaction
92    remaining_block_bytes -= fake_coinbase_tx.data.as_ref().len();
93    remaining_block_sigops -= fake_coinbase_tx.sigops;
94
95    // > Repeat while there is any candidate transaction
96    // > that pays at least the conventional fee:
97    let mut conventional_fee_tx_weights = setup_fee_weighted_index(&conventional_fee_txs);
98
99    while let Some(tx_weights) = conventional_fee_tx_weights {
100        conventional_fee_tx_weights = checked_add_transaction_weighted_random(
101            &mut conventional_fee_txs,
102            &mut dependent_mempool_txs,
103            tx_weights,
104            &mut selected_txs,
105            &mempool_tx_deps,
106            &mut remaining_block_bytes,
107            &mut remaining_block_sigops,
108            // The number of unpaid actions is always zero for transactions that pay the
109            // conventional fee, so this check and limit is effectively ignored.
110            &mut remaining_block_unpaid_actions,
111        );
112    }
113
114    // > Repeat while there is any candidate transaction:
115    let mut low_fee_tx_weights = setup_fee_weighted_index(&low_fee_txs);
116
117    while let Some(tx_weights) = low_fee_tx_weights {
118        low_fee_tx_weights = checked_add_transaction_weighted_random(
119            &mut low_fee_txs,
120            &mut dependent_mempool_txs,
121            tx_weights,
122            &mut selected_txs,
123            &mempool_tx_deps,
124            &mut remaining_block_bytes,
125            &mut remaining_block_sigops,
126            &mut remaining_block_unpaid_actions,
127        );
128    }
129
130    selected_txs
131}
132
133/// Returns a fake coinbase transaction that can be used during transaction selection.
134///
135/// This avoids a data dependency loop involving the selected transactions, the miner fee,
136/// and the coinbase transaction.
137///
138/// This transaction's serialized size and sigops must be at least as large as the real coinbase
139/// transaction with the correct height and fee.
140pub fn fake_coinbase_transaction(
141    net: &Network,
142    height: Height,
143    miner_address: &Address,
144    extra_coinbase_data: Vec<u8>,
145) -> TransactionTemplate<NegativeOrZero> {
146    // Block heights are encoded as variable-length (script) and `u32` (lock time, expiry height).
147    // They can also change the `u32` consensus branch id.
148    // We use the template height here, which has the correct byte length.
149    // https://zips.z.cash/protocol/protocol.pdf#txnconsensus
150    // https://github.com/zcash/zips/blob/main/zip-0203.rst#changes-for-nu5
151    //
152    // Transparent amounts are encoded as `i64`,
153    // so one zat has the same size as the real amount:
154    // https://developer.bitcoin.org/reference/transactions.html#txout-a-transaction-output
155    let miner_fee = 1.try_into().expect("amount is valid and non-negative");
156    let outputs = standard_coinbase_outputs(net, height, miner_address, miner_fee);
157    let coinbase = Transaction::new_v5_coinbase(net, height, outputs, extra_coinbase_data).into();
158
159    TransactionTemplate::from_coinbase(&coinbase, miner_fee)
160}
161
162/// Returns a fee-weighted index and the total weight of `transactions`.
163///
164/// Returns `None` if there are no transactions, or if the weights are invalid.
165fn setup_fee_weighted_index(transactions: &[VerifiedUnminedTx]) -> Option<WeightedIndex<f32>> {
166    if transactions.is_empty() {
167        return None;
168    }
169
170    let tx_weights: Vec<f32> = transactions.iter().map(|tx| tx.fee_weight_ratio).collect();
171
172    // Setup the transaction weights.
173    WeightedIndex::new(tx_weights).ok()
174}
175
176/// Checks if every item in `candidate_tx_deps` is present in `selected_txs`.
177///
178/// Requires items in `selected_txs` to be unique to work correctly.
179fn has_direct_dependencies(
180    candidate_tx_deps: Option<&HashSet<transaction::Hash>>,
181    selected_txs: &Vec<SelectedMempoolTx>,
182) -> bool {
183    let Some(deps) = candidate_tx_deps else {
184        return true;
185    };
186
187    if selected_txs.len() < deps.len() {
188        return false;
189    }
190
191    let mut num_available_deps = 0;
192    for tx in selected_txs {
193        #[cfg(test)]
194        let (_, tx) = tx;
195        if deps.contains(&tx.transaction.id.mined_id()) {
196            num_available_deps += 1;
197        } else {
198            continue;
199        }
200
201        if num_available_deps == deps.len() {
202            return true;
203        }
204    }
205
206    false
207}
208
209/// Returns the depth of a transaction's dependencies in the block for a candidate
210/// transaction with the provided dependencies.
211#[cfg(test)]
212fn dependencies_depth(
213    dependent_tx_id: &transaction::Hash,
214    mempool_tx_deps: &TransactionDependencies,
215) -> InBlockTxDependenciesDepth {
216    let mut current_level = 0;
217    let mut current_level_deps = mempool_tx_deps.direct_dependencies(dependent_tx_id);
218    while !current_level_deps.is_empty() {
219        current_level += 1;
220        current_level_deps = current_level_deps
221            .iter()
222            .flat_map(|dep_id| mempool_tx_deps.direct_dependencies(dep_id))
223            .collect();
224    }
225
226    current_level
227}
228
229/// Chooses a random transaction from `txs` using the weighted index `tx_weights`,
230/// and tries to add it to `selected_txs`.
231///
232/// If it fits in the supplied limits, adds it to `selected_txs`, and updates the limits.
233///
234/// Updates the weights of chosen transactions to zero, even if they weren't added,
235/// so they can't be chosen again.
236///
237/// Returns the updated transaction weights.
238/// If all transactions have been chosen, returns `None`.
239// TODO: Refactor these arguments into a struct and this function into a method.
240#[allow(clippy::too_many_arguments)]
241fn checked_add_transaction_weighted_random(
242    candidate_txs: &mut Vec<VerifiedUnminedTx>,
243    dependent_txs: &mut HashMap<transaction::Hash, VerifiedUnminedTx>,
244    tx_weights: WeightedIndex<f32>,
245    selected_txs: &mut Vec<SelectedMempoolTx>,
246    mempool_tx_deps: &TransactionDependencies,
247    remaining_block_bytes: &mut usize,
248    remaining_block_sigops: &mut u32,
249    remaining_block_unpaid_actions: &mut u32,
250) -> Option<WeightedIndex<f32>> {
251    // > Pick one of those transactions at random with probability in direct proportion
252    // > to its weight_ratio, and remove it from the set of candidate transactions
253    let (new_tx_weights, candidate_tx) =
254        choose_transaction_weighted_random(candidate_txs, tx_weights);
255
256    if !candidate_tx.try_update_block_template_limits(
257        remaining_block_bytes,
258        remaining_block_sigops,
259        remaining_block_unpaid_actions,
260    ) {
261        return new_tx_weights;
262    }
263
264    let tx_dependencies = mempool_tx_deps.dependencies();
265    let selected_tx_id = &candidate_tx.transaction.id.mined_id();
266    debug_assert!(
267        !tx_dependencies.contains_key(selected_tx_id),
268        "all candidate transactions should be independent"
269    );
270
271    #[cfg(not(test))]
272    selected_txs.push(candidate_tx);
273
274    #[cfg(test)]
275    selected_txs.push((0, candidate_tx));
276
277    // Try adding any dependent transactions if all of their dependencies have been selected.
278
279    let mut current_level_dependents = mempool_tx_deps.direct_dependents(selected_tx_id);
280    while !current_level_dependents.is_empty() {
281        let mut next_level_dependents = HashSet::new();
282
283        for dependent_tx_id in &current_level_dependents {
284            // ## Note
285            //
286            // A necessary condition for adding the dependent tx is that it spends unmined outputs coming only from
287            // the selected txs, which come from the mempool. If the tx also spends in-chain outputs, it won't
288            // be added. This behavior is not specified by consensus rules and can be changed at any time,
289            // meaning that such txs could be added.
290            if has_direct_dependencies(tx_dependencies.get(dependent_tx_id), selected_txs) {
291                let Some(candidate_tx) = dependent_txs.remove(dependent_tx_id) else {
292                    continue;
293                };
294
295                // Transactions that don't pay the conventional fee should not have
296                // the same probability of being included as their dependencies.
297                if !candidate_tx.pays_conventional_fee() {
298                    continue;
299                }
300
301                if !candidate_tx.try_update_block_template_limits(
302                    remaining_block_bytes,
303                    remaining_block_sigops,
304                    remaining_block_unpaid_actions,
305                ) {
306                    continue;
307                }
308
309                #[cfg(not(test))]
310                selected_txs.push(candidate_tx);
311
312                #[cfg(test)]
313                selected_txs.push((
314                    dependencies_depth(dependent_tx_id, mempool_tx_deps),
315                    candidate_tx,
316                ));
317
318                next_level_dependents.extend(mempool_tx_deps.direct_dependents(dependent_tx_id));
319            }
320        }
321
322        current_level_dependents = next_level_dependents;
323    }
324
325    new_tx_weights
326}
327
328trait TryUpdateBlockLimits {
329    /// Checks if a transaction fits within the provided remaining block bytes,
330    /// sigops, and unpaid actions limits.
331    ///
332    /// Updates the limits and returns true if the transaction does fit, or
333    /// returns false otherwise.
334    fn try_update_block_template_limits(
335        &self,
336        remaining_block_bytes: &mut usize,
337        remaining_block_sigops: &mut u32,
338        remaining_block_unpaid_actions: &mut u32,
339    ) -> bool;
340}
341
342impl TryUpdateBlockLimits for VerifiedUnminedTx {
343    fn try_update_block_template_limits(
344        &self,
345        remaining_block_bytes: &mut usize,
346        remaining_block_sigops: &mut u32,
347        remaining_block_unpaid_actions: &mut u32,
348    ) -> bool {
349        // > If the block template with this transaction included
350        // > would be within the block size limit and block sigop limit,
351        // > and block_unpaid_actions <=  block_unpaid_action_limit,
352        // > add the transaction to the block template
353        //
354        // Unpaid actions are always zero for transactions that pay the conventional fee,
355        // so the unpaid action check always passes for those transactions.
356        if self.transaction.size <= *remaining_block_bytes
357            && self.sigops <= *remaining_block_sigops
358            && self.unpaid_actions <= *remaining_block_unpaid_actions
359        {
360            *remaining_block_bytes -= self.transaction.size;
361            *remaining_block_sigops -= self.sigops;
362
363            // Unpaid actions are always zero for transactions that pay the conventional fee,
364            // so this limit always remains the same after they are added.
365            *remaining_block_unpaid_actions -= self.unpaid_actions;
366
367            true
368        } else {
369            false
370        }
371    }
372}
373
374/// Choose a transaction from `transactions`, using the previously set up `weighted_index`.
375///
376/// If some transactions have not yet been chosen, returns the weighted index and the transaction.
377/// Otherwise, just returns the transaction.
378fn choose_transaction_weighted_random(
379    candidate_txs: &mut Vec<VerifiedUnminedTx>,
380    weighted_index: WeightedIndex<f32>,
381) -> (Option<WeightedIndex<f32>>, VerifiedUnminedTx) {
382    let candidate_position = weighted_index.sample(&mut thread_rng());
383    let candidate_tx = candidate_txs.swap_remove(candidate_position);
384
385    // We have to regenerate this index each time we choose a transaction, due to floating-point sum inaccuracies.
386    // If we don't, some chosen transactions can end up with a tiny non-zero weight, leading to duplicates.
387    // <https://github.com/rust-random/rand/blob/4bde8a0adb517ec956fcec91665922f6360f974b/src/distributions/weighted_index.rs#L173-L183>
388    (setup_fee_weighted_index(candidate_txs), candidate_tx)
389}