kaspa_mining/block_template/
selector.rs

1use kaspa_core::{time::Stopwatch, trace};
2use rand::Rng;
3use std::collections::HashMap;
4
5use crate::model::candidate_tx::CandidateTransaction;
6
7use super::{
8    model::tx::{CandidateList, SelectableTransaction, SelectableTransactions, TransactionIndex},
9    policy::Policy,
10};
11use kaspa_consensus_core::{
12    block::TemplateTransactionSelector,
13    subnets::SubnetworkId,
14    tx::{Transaction, TransactionId},
15};
16
17/// ALPHA is a coefficient that defines how uniform the distribution of
18/// candidate transactions should be. A smaller alpha makes the distribution
19/// more uniform. ALPHA is used when determining a candidate transaction's
20/// initial p value.
21pub(crate) const ALPHA: i32 = 3;
22
23/// REBALANCE_THRESHOLD is the percentage of candidate transactions under which
24/// we don't rebalance. Rebalancing is a heavy operation so we prefer to avoid
25/// rebalancing very often. On the other hand, if we don't rebalance often enough
26/// we risk having too many collisions.
27/// The value is derived from the max probability of collision. That is to say,
28/// if REBALANCE_THRESHOLD is 0.95, there's a 1-in-20 chance of collision.
29const REBALANCE_THRESHOLD: f64 = 0.95;
30
31pub struct RebalancingWeightedTransactionSelector {
32    policy: Policy,
33    /// Transaction store
34    transactions: Vec<CandidateTransaction>,
35    /// Selectable transactions store
36    selectable_txs: SelectableTransactions,
37
38    /// Indexes of selected transactions in stores
39    selected_txs: Vec<TransactionIndex>,
40
41    /// Optional state for handling selection rejections. Maps from a selected tx id
42    /// to the index of the tx in the `transactions` vec
43    selected_txs_map: Option<HashMap<TransactionId, TransactionIndex>>,
44
45    // Inner state of the selection process
46    candidate_list: CandidateList,
47    overall_rejections: usize,
48    used_count: usize,
49    used_p: f64,
50    total_mass: u64,
51    total_fees: u64,
52    gas_usage_map: HashMap<SubnetworkId, u64>,
53}
54
55impl RebalancingWeightedTransactionSelector {
56    pub fn new(policy: Policy, mut transactions: Vec<CandidateTransaction>) -> Self {
57        let _sw = Stopwatch::<100>::with_threshold("TransactionsSelector::new op");
58        // Sort the transactions by subnetwork_id.
59        transactions.sort_by(|a, b| a.tx.subnetwork_id.cmp(&b.tx.subnetwork_id));
60
61        // Create the object without selectable transactions
62        let mut selector = Self {
63            policy,
64            transactions,
65            selectable_txs: Default::default(),
66            selected_txs: Default::default(),
67            selected_txs_map: None,
68            candidate_list: Default::default(),
69            overall_rejections: 0,
70            used_count: 0,
71            used_p: 0.0,
72            total_mass: 0,
73            total_fees: 0,
74            gas_usage_map: Default::default(),
75        };
76
77        // Create the selectable transactions
78        selector.selectable_txs =
79            selector.transactions.iter().map(|x| SelectableTransaction::new(selector.calc_tx_value(x), 0, ALPHA)).collect();
80        // Prepare the initial candidate list
81        selector.candidate_list = CandidateList::new(&selector.selectable_txs);
82
83        selector
84    }
85
86    /// select_transactions implements a probabilistic transaction selection algorithm.
87    /// The algorithm, roughly, is as follows:
88    /// 1. We assign a probability to each transaction equal to:
89    ///    (candidateTx.Value^alpha) / Σ(tx.Value^alpha)
90    ///    Where the sum of the probabilities of all txs is 1.
91    /// 2. We draw a random number in [0,1) and select a transaction accordingly.
92    /// 3. If it's valid, add it to the selectedTxs and remove it from the candidates.
93    /// 4. Continue iterating the above until we have either selected all
94    ///    available transactions or ran out of gas/block space.
95    ///
96    /// Note that we make two optimizations here:
97    /// * Draw a number in [0,Σ(tx.Value^alpha)) to avoid normalization
98    /// * Instead of removing a candidate after each iteration, mark it for deletion.
99    ///   Once the sum of probabilities of marked transactions is greater than
100    ///   REBALANCE_THRESHOLD percent of the sum of probabilities of all transactions,
101    ///   rebalance.
102    ///
103    /// select_transactions loops over the candidate transactions
104    /// and appends the ones that will be included in the next block into
105    /// selected_txs.
106    pub fn select_transactions(&mut self) -> Vec<Transaction> {
107        let _sw = Stopwatch::<15>::with_threshold("select_transaction op");
108        let mut rng = rand::thread_rng();
109
110        self.reset_selection();
111
112        while self.candidate_list.candidates.len() - self.used_count > 0 {
113            // Rebalance the candidates if it's required
114            if self.used_p >= REBALANCE_THRESHOLD * self.candidate_list.total_p {
115                self.candidate_list = self.candidate_list.rebalanced(&self.selectable_txs);
116                self.used_count = 0;
117                self.used_p = 0.0;
118
119                // Break if we now ran out of transactions
120                if self.candidate_list.is_empty() {
121                    break;
122                }
123            }
124
125            // Select a candidate tx at random
126            let r = rng.gen::<f64>() * self.candidate_list.total_p;
127            let selected_candidate_idx = self.candidate_list.find(r);
128            let selected_candidate = self.candidate_list.candidates.get_mut(selected_candidate_idx).unwrap();
129
130            // If is_marked_for_deletion is set, it means we got a collision.
131            // Ignore and select another Tx.
132            if selected_candidate.is_marked_for_deletion {
133                continue;
134            }
135            let selected_tx = &self.transactions[selected_candidate.index];
136
137            // Enforce maximum transaction mass per block.
138            // Also check for overflow.
139            let next_total_mass = self.total_mass.checked_add(selected_tx.calculated_mass);
140            if next_total_mass.is_none() || next_total_mass.unwrap() > self.policy.max_block_mass {
141                trace!("Tx {0} would exceed the max block mass. As such, stopping.", selected_tx.tx.id());
142                break;
143            }
144
145            // Enforce maximum gas per subnetwork per block.
146            // Also check for overflow.
147            if !selected_tx.tx.subnetwork_id.is_builtin_or_native() {
148                let subnetwork_id = selected_tx.tx.subnetwork_id.clone();
149                let gas_usage = self.gas_usage_map.entry(subnetwork_id.clone()).or_insert(0);
150                let tx_gas = selected_tx.tx.gas;
151                let next_gas_usage = (*gas_usage).checked_add(tx_gas);
152                if next_gas_usage.is_none() || next_gas_usage.unwrap() > self.selectable_txs[selected_candidate.index].gas_limit {
153                    trace!(
154                        "Tx {0} would exceed the gas limit in subnetwork {1}. Removing all remaining txs from this subnetwork.",
155                        selected_tx.tx.id(),
156                        subnetwork_id
157                    );
158                    for i in selected_candidate_idx..self.candidate_list.candidates.len() {
159                        let transaction_index = self.candidate_list.candidates[i].index;
160                        // Candidate txs are ordered by subnetwork, so we can safely assume
161                        // that transactions after subnetwork_id will not be relevant.
162                        if subnetwork_id < self.transactions[transaction_index].tx.subnetwork_id {
163                            break;
164                        }
165                        let current = self.candidate_list.candidates.get_mut(i).unwrap();
166
167                        // Mark for deletion
168                        current.is_marked_for_deletion = true;
169                        self.used_count += 1;
170                        self.used_p += self.selectable_txs[transaction_index].p;
171                    }
172                    continue;
173                }
174                // Here we know that next_gas_usage is some (since no overflow occurred) so we can safely unwrap.
175                *gas_usage = next_gas_usage.unwrap();
176            }
177
178            // Add the transaction to the result, increment counters, and
179            // save the masses, fees, and signature operation counts to the
180            // result.
181            self.selected_txs.push(selected_candidate.index);
182            self.total_mass += selected_tx.calculated_mass;
183            self.total_fees += selected_tx.calculated_fee;
184
185            trace!("Adding tx {0} (fee per gram: {1})", selected_tx.tx.id(), selected_tx.calculated_fee / selected_tx.calculated_mass);
186
187            // Mark for deletion
188            selected_candidate.is_marked_for_deletion = true;
189            self.used_count += 1;
190            self.used_p += self.selectable_txs[selected_candidate.index].p;
191        }
192
193        self.selected_txs.sort();
194
195        self.get_transactions()
196    }
197
198    fn get_transactions(&self) -> Vec<Transaction> {
199        // These transactions leave the selector so we clone
200        self.selected_txs.iter().map(|x| self.transactions[*x].tx.as_ref().clone()).collect()
201    }
202
203    fn reset_selection(&mut self) {
204        assert_eq!(self.transactions.len(), self.selectable_txs.len());
205        self.selected_txs.clear();
206        // TODO: consider to min with the approximated amount of txs which fit into max block mass
207        self.selected_txs.reserve_exact(self.transactions.len());
208        self.selected_txs_map = None;
209    }
210
211    /// calc_tx_value calculates a value to be used in transaction selection.
212    /// The higher the number the more likely it is that the transaction will be
213    /// included in the block.
214    fn calc_tx_value(&self, transaction: &CandidateTransaction) -> f64 {
215        let mass_limit = self.policy.max_block_mass as f64;
216        let mass = transaction.calculated_mass as f64;
217        let fee = transaction.calculated_fee as f64;
218        if transaction.tx.subnetwork_id.is_builtin_or_native() {
219            fee / mass / mass_limit
220        } else {
221            // TODO: Replace with real gas once implemented
222            let gas_limit = u64::MAX as f64;
223            fee / mass / mass_limit + transaction.tx.gas as f64 / gas_limit
224        }
225    }
226}
227
228impl TemplateTransactionSelector for RebalancingWeightedTransactionSelector {
229    fn select_transactions(&mut self) -> Vec<Transaction> {
230        self.select_transactions()
231    }
232
233    fn reject_selection(&mut self, tx_id: TransactionId) {
234        let selected_txs_map = self
235            .selected_txs_map
236            // We lazy-create the map only when there are actual rejections
237            .get_or_insert_with(|| self.selected_txs.iter().map(|&x| (self.transactions[x].tx.id(), x)).collect());
238        let tx_index = selected_txs_map.remove(&tx_id).expect("only previously selected txs can be rejected (and only once)");
239        let tx = &self.transactions[tx_index];
240        self.total_mass -= tx.calculated_mass;
241        self.total_fees -= tx.calculated_fee;
242        if !tx.tx.subnetwork_id.is_builtin_or_native() {
243            *self.gas_usage_map.get_mut(&tx.tx.subnetwork_id).expect("previously selected txs have an entry") -= tx.tx.gas;
244        }
245        self.overall_rejections += 1;
246    }
247
248    fn is_successful(&self) -> bool {
249        const SUFFICIENT_MASS_THRESHOLD: f64 = 0.8;
250        const LOW_REJECTION_FRACTION: f64 = 0.2;
251
252        // We consider the operation successful if either mass occupation is above 80% or rejection rate is below 20%
253        self.overall_rejections == 0
254            || (self.total_mass as f64) > self.policy.max_block_mass as f64 * SUFFICIENT_MASS_THRESHOLD
255            || (self.overall_rejections as f64) < self.transactions.len() as f64 * LOW_REJECTION_FRACTION
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use itertools::Itertools;
263    use kaspa_consensus_core::{
264        constants::{MAX_TX_IN_SEQUENCE_NUM, SOMPI_PER_KASPA, TX_VERSION},
265        mass::transaction_estimated_serialized_size,
266        subnets::SUBNETWORK_ID_NATIVE,
267        tx::{Transaction, TransactionId, TransactionInput, TransactionOutpoint, TransactionOutput},
268    };
269    use kaspa_txscript::{pay_to_script_hash_signature_script, test_helpers::op_true_script};
270    use std::{collections::HashSet, sync::Arc};
271
272    use crate::{
273        mempool::{
274            config::DEFAULT_MINIMUM_RELAY_TRANSACTION_FEE,
275            model::frontier::selectors::{SequenceSelector, SequenceSelectorInput, SequenceSelectorTransaction},
276        },
277        model::candidate_tx::CandidateTransaction,
278    };
279
280    #[test]
281    fn test_reject_transaction() {
282        const TX_INITIAL_COUNT: usize = 1_000;
283
284        // Create a vector of transactions differing by output value so they have unique ids
285        let transactions = (0..TX_INITIAL_COUNT).map(|i| create_transaction(SOMPI_PER_KASPA * (i + 1) as u64)).collect_vec();
286        let masses: HashMap<_, _> = transactions.iter().map(|tx| (tx.tx.id(), tx.calculated_mass)).collect();
287        let sequence: SequenceSelectorInput =
288            transactions.iter().map(|tx| SequenceSelectorTransaction::new(tx.tx.clone(), tx.calculated_mass)).collect();
289
290        let policy = Policy::new(100_000);
291        let selectors: [Box<dyn TemplateTransactionSelector>; 2] = [
292            Box::new(RebalancingWeightedTransactionSelector::new(policy.clone(), transactions)),
293            Box::new(SequenceSelector::new(sequence, policy.clone())),
294        ];
295
296        for mut selector in selectors {
297            let (mut kept, mut rejected) = (HashSet::new(), HashSet::new());
298            let mut reject_count = 32;
299            let mut total_mass = 0;
300            for i in 0..10 {
301                let selected_txs = selector.select_transactions();
302                if i > 0 {
303                    assert_eq!(
304                        selected_txs.len(),
305                        reject_count,
306                        "subsequent select calls are expected to only refill the previous rejections"
307                    );
308                    reject_count /= 2;
309                }
310                for tx in selected_txs.iter() {
311                    total_mass += masses[&tx.id()];
312                    kept.insert(tx.id()).then_some(()).expect("selected txs should never repeat themselves");
313                    assert!(!rejected.contains(&tx.id()), "selected txs should never repeat themselves");
314                }
315                assert!(total_mass <= policy.max_block_mass);
316                selected_txs.iter().take(reject_count).for_each(|x| {
317                    total_mass -= masses[&x.id()];
318                    selector.reject_selection(x.id());
319                    kept.remove(&x.id()).then_some(()).expect("was just inserted");
320                    rejected.insert(x.id()).then_some(()).expect("was just verified");
321                });
322            }
323        }
324    }
325
326    fn create_transaction(value: u64) -> CandidateTransaction {
327        let previous_outpoint = TransactionOutpoint::new(TransactionId::default(), 0);
328        let (script_public_key, redeem_script) = op_true_script();
329        let signature_script = pay_to_script_hash_signature_script(redeem_script, vec![]).expect("the redeem script is canonical");
330
331        let input = TransactionInput::new(previous_outpoint, signature_script, MAX_TX_IN_SEQUENCE_NUM, 1);
332        let output = TransactionOutput::new(value - DEFAULT_MINIMUM_RELAY_TRANSACTION_FEE, script_public_key);
333        let tx = Arc::new(Transaction::new(TX_VERSION, vec![input], vec![output], 0, SUBNETWORK_ID_NATIVE, 0, vec![]));
334        let calculated_mass = transaction_estimated_serialized_size(&tx);
335        let calculated_fee = DEFAULT_MINIMUM_RELAY_TRANSACTION_FEE;
336
337        CandidateTransaction { tx, calculated_fee, calculated_mass }
338    }
339}