Skip to main content

kaspa_txscript/
covenants.rs

1use kaspa_consensus_core::{
2    hashing,
3    tx::{CovenantBinding, VerifiableTransaction},
4};
5use kaspa_hashes::Hash;
6use kaspa_txscript_errors::CovenantsError;
7use std::{collections::HashMap, sync::LazyLock};
8
9/// Context for an input's specific authority over a subset of outputs.
10///
11/// Used by scripts to verify the state transitions they directly authorized
12/// (e.g., 1-to-N splits) without scanning unrelated outputs.
13#[cfg_attr(test, derive(PartialEq, Eq, Debug))]
14pub struct CovenantInputContext {
15    /// Indices of outputs that explicitly declare this input as their `authorizing_input`.
16    ///
17    /// This defines the input's direct "children" in the transaction.
18    pub auth_outputs: Vec<usize>,
19}
20
21impl Default for CovenantInputContext {
22    fn default() -> Self {
23        Self::new()
24    }
25}
26
27impl CovenantInputContext {
28    pub fn new() -> Self {
29        Self { auth_outputs: Default::default() }
30    }
31}
32
33/// Context for the shared transaction-wide state of a specific Covenant ID.
34///
35/// Used for verifying global invariants across all participants of the same covenant
36/// (e.g., merges, batching, or conservation of amounts).
37#[derive(Default)]
38#[cfg_attr(test, derive(PartialEq, Eq, Debug))]
39pub struct CovenantSharedContext {
40    /// Indices of *all* inputs in the transaction carrying this `covenant_id`.
41    pub input_indices: Vec<usize>,
42
43    /// Indices of *all* outputs in the transaction carrying this `covenant_id`.
44    pub output_indices: Vec<usize>,
45}
46
47/// Pre-computed cache mapping inputs and covenant ids to their execution contexts.
48///
49/// Enables O(1) access for covenant introspection opcodes.
50#[derive(Default)]
51#[cfg_attr(test, derive(PartialEq, Eq, Debug))]
52pub struct CovenantsContext {
53    /// Maps an input index to its local authority context.
54    pub input_ctxs: HashMap<usize, CovenantInputContext>,
55
56    /// Maps a covenant id to its shared transaction-wide context.
57    pub shared_ctxs: HashMap<Hash, CovenantSharedContext>,
58}
59
60pub static EMPTY_COV_CONTEXT: LazyLock<CovenantsContext> = LazyLock::new(CovenantsContext::default);
61
62impl CovenantsContext {
63    /// Returns the absolute transaction output index of the k-th authorized output.
64    ///
65    /// Missing input contexts are treated as having zero authorized outputs.
66    pub(crate) fn auth_output_index(&self, input_idx: usize, k: usize) -> Result<usize, CovenantsError> {
67        let auth_outputs = self.input_ctxs.get(&input_idx).map(|ctx| ctx.auth_outputs.as_slice()).unwrap_or_default();
68        auth_outputs.get(k).copied().ok_or(CovenantsError::InvalidAuthCovOutIndex(k, input_idx, auth_outputs.len()))
69    }
70
71    /// Returns the number of outputs authorized by this input.
72    ///
73    /// Missing input contexts are treated as having zero authorized outputs.
74    pub(crate) fn num_auth_outputs(&self, input_idx: usize) -> usize {
75        self.input_ctxs.get(&input_idx).map_or(0, |ctx| ctx.auth_outputs.len())
76    }
77
78    pub(crate) fn num_covenant_inputs(&self, covenant_id: Hash) -> usize {
79        self.shared_ctxs.get(&covenant_id).map_or(0, |ctx| ctx.input_indices.len())
80    }
81
82    pub(crate) fn covenant_input_index(&self, covenant_id: Hash, k: usize) -> Result<usize, CovenantsError> {
83        let input_indices = self.shared_ctxs.get(&covenant_id).map(|ctx| ctx.input_indices.as_slice()).unwrap_or_default();
84        input_indices.get(k).copied().ok_or(CovenantsError::InvalidCovInIndex(covenant_id, k))
85    }
86
87    pub(crate) fn num_covenant_outputs(&self, covenant_id: Hash) -> usize {
88        self.shared_ctxs.get(&covenant_id).map_or(0, |ctx| ctx.output_indices.len())
89    }
90
91    pub(crate) fn covenant_output_index(&self, covenant_id: Hash, k: usize) -> Result<usize, CovenantsError> {
92        let output_indices = self.shared_ctxs.get(&covenant_id).map(|ctx| ctx.output_indices.as_slice()).unwrap_or_default();
93        output_indices.get(k).copied().ok_or(CovenantsError::InvalidCovOutIndex(covenant_id, k))
94    }
95
96    /// Constructs the covenants execution context for a transaction.
97    ///
98    /// Collects per-input and shared covenant relations from the transaction,
99    /// validating covenant bindings and handling both continuation and genesis
100    /// cases. Genesis outputs are validated but do not populate covenant contexts.
101    pub fn from_tx(tx: &impl VerifiableTransaction) -> Result<Self, CovenantsError> {
102        let mut ctx = CovenantsContext::default();
103
104        // Aggregated per (authorizing input, covenant id) genesis groups.
105        let mut genesis_ctxs: HashMap<(usize, Hash), Vec<usize>> = HashMap::new();
106
107        for (i, (_, entry)) in tx.populated_inputs().enumerate() {
108            if let Some(covenant_id) = entry.covenant_id {
109                ctx.shared_ctxs.entry(covenant_id).or_default().input_indices.push(i);
110            }
111        }
112
113        for (i, output) in tx.outputs().iter().enumerate() {
114            let Some(CovenantBinding { covenant_id, authorizing_input }) = output.covenant else {
115                continue;
116            };
117
118            let auth_input_idx = authorizing_input as usize;
119
120            let Some(utxo_entry) = tx.utxo(auth_input_idx) else {
121                return Err(CovenantsError::AuthInputOutOfBounds(i, authorizing_input));
122            };
123
124            match utxo_entry.covenant_id {
125                Some(input_covenant_id) if input_covenant_id == covenant_id => {
126                    // Continuation case: the authorizing input already carries the same covenant id.
127                    // Record the output under both the per-input context and the shared covenant context.
128
129                    ctx.input_ctxs.entry(auth_input_idx).or_insert_with(CovenantInputContext::new).auth_outputs.push(i);
130
131                    ctx.shared_ctxs
132                        .get_mut(&covenant_id)
133                        .expect("Shared context should've been created by the authorizing input")
134                        .output_indices
135                        .push(i);
136                }
137                Some(_) | None => {
138                    // Genesis case: the authorizing input does not carry this covenant id (either absent or different).
139                    // Treat the output as a genesis-authorized output for the pair (authorizing input, covenant id).
140                    // These relations are validated via covenant-id reconstruction, but are not added to the script-engine contexts.
141
142                    genesis_ctxs.entry((auth_input_idx, covenant_id)).or_default().push(i);
143                }
144            }
145        }
146
147        // Validate genesis covenant ids by recomputing the id for each (authorizing input, covenant id) group
148        // from the genesis outpoint and the group's authorized outputs.
149        for ((auth_input_idx, covenant_id), output_indices) in genesis_ctxs.into_iter() {
150            let input = tx.inputs().get(auth_input_idx).expect("utxo(auth_input) existed above");
151
152            let expected_id = hashing::covenant_id::covenant_id(
153                input.previous_outpoint,
154                output_indices.into_iter().map(|i| (i as u32, tx.outputs().get(i).expect("enumerated above"))),
155            );
156
157            if expected_id != covenant_id {
158                return Err(CovenantsError::WrongGenesisCovenantId(auth_input_idx, covenant_id));
159            }
160        }
161
162        Ok(ctx)
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use kaspa_consensus_core::{
170        hashing,
171        subnets::SubnetworkId,
172        tx::{
173            CovenantBinding, GenesisCovenantGroup, PopulatedTransaction, Transaction, TransactionInput, TransactionOutpoint,
174            TransactionOutput, UtxoEntry,
175        },
176    };
177
178    struct OutputConfig {
179        value: u64,
180        authorizing_input: usize,
181        covenant_group: u64, // Outputs with the same group id share a covenant id
182    }
183
184    /// Creates a transaction with configurable inputs and outputs for testing both genesis and continuation cases.
185    ///
186    /// - `input_covenant_ids`: Covenant id for each input (None for no covenant, Some for covenant-carrying inputs)
187    /// - `outputs`: Configuration for each output
188    /// - `compute_correct_ids`: If true, computes covenant ids for genesis outputs via hashing;
189    ///   continuation outputs (where input covenant matches covenant_group) use covenant_group as-is
190    fn create_genesis_tx(
191        input_covenant_ids: Vec<Option<u64>>,
192        outputs: Vec<OutputConfig>,
193        compute_correct_ids: bool,
194    ) -> (Transaction, Vec<UtxoEntry>) {
195        // Create inputs and UTXOs
196        let inputs: Vec<_> = input_covenant_ids
197            .iter()
198            .enumerate()
199            .map(|(i, _)| {
200                let outpoint = TransactionOutpoint::new((i as u64).into(), 0);
201                TransactionInput::new(outpoint, vec![], 0, 0)
202            })
203            .collect();
204
205        let utxos: Vec<_> = input_covenant_ids
206            .iter()
207            .map(|&cov_id| UtxoEntry {
208                amount: 1000,
209                script_public_key: Default::default(),
210                block_daa_score: 0,
211                is_coinbase: false,
212                covenant_id: cov_id.map(|id| id.into()),
213            })
214            .collect();
215
216        // Create outputs with placeholder covenant ids
217        let tx_outputs: Vec<_> = outputs
218            .iter()
219            .map(|cfg| TransactionOutput {
220                value: cfg.value,
221                script_public_key: Default::default(),
222                covenant: Some(CovenantBinding {
223                    covenant_id: cfg.covenant_group.into(), // Correct for continuation, placeholder for genesis
224                    authorizing_input: cfg.authorizing_input as u16,
225                }),
226            })
227            .collect();
228
229        let mut tx = Transaction::new(0, inputs, tx_outputs, 0, SubnetworkId::default(), 0, vec![]);
230
231        if compute_correct_ids {
232            // Collect genesis outputs and compute their covenant ids (continuation outputs already have correct covenant_id == covenant_group)
233            let mut genesis_groups: HashMap<(usize, u64), Vec<usize>> = HashMap::new();
234
235            for (i, cfg) in outputs.iter().enumerate() {
236                let input_cov_id = input_covenant_ids.get(cfg.authorizing_input).copied().flatten();
237
238                if input_cov_id != Some(cfg.covenant_group) {
239                    genesis_groups.entry((cfg.authorizing_input, cfg.covenant_group)).or_default().push(i);
240                }
241            }
242
243            let genesis_groups: Vec<(usize, Vec<usize>)> =
244                genesis_groups.into_iter().map(|((auth_input_idx, _), output_indices)| (auth_input_idx, output_indices)).collect();
245
246            for (auth_input_idx, output_indices) in &genesis_groups {
247                let outpoint = tx.inputs[*auth_input_idx].previous_outpoint;
248                let expected_id =
249                    hashing::covenant_id::covenant_id(outpoint, output_indices.iter().map(|&i| (i as u32, &tx.outputs[i])));
250                for &output_idx in output_indices {
251                    tx.outputs[output_idx].covenant.as_mut().unwrap().covenant_id = expected_id;
252                }
253            }
254
255            // Verify helper API parity while keeping the existing manual construction.
256            let mut api_tx = tx.clone();
257            for &output_idx in genesis_groups.iter().flat_map(|(_, output_indices)| output_indices.iter()) {
258                api_tx.outputs[output_idx].covenant = None;
259            }
260            let api_groups: Vec<_> = genesis_groups
261                .iter()
262                .map(|(auth_input_idx, output_indices)| {
263                    GenesisCovenantGroup::new(*auth_input_idx as u16, output_indices.iter().map(|&i| i as u32).collect())
264                })
265                .collect();
266            api_tx.populate_genesis_covenants(&api_groups).unwrap();
267
268            for &output_idx in genesis_groups.iter().flat_map(|(_, output_indices)| output_indices.iter()) {
269                assert_eq!(tx.outputs[output_idx].covenant, api_tx.outputs[output_idx].covenant);
270            }
271        }
272
273        tx.finalize();
274        (tx, utxos)
275    }
276
277    #[test]
278    fn test_genesis_single_output() {
279        let (tx, entries) =
280            create_genesis_tx(vec![None], vec![OutputConfig { value: 100, authorizing_input: 0, covenant_group: 1 }], true);
281        let populated_tx = PopulatedTransaction::new(&tx, entries);
282        let ctx = CovenantsContext::from_tx(&populated_tx).unwrap();
283
284        // For genesis, contexts should be empty since genesis outputs don't populate contexts
285        assert!(ctx.input_ctxs.is_empty());
286        assert!(ctx.shared_ctxs.is_empty());
287    }
288
289    #[test]
290    fn test_genesis_multiple_outputs() {
291        let (tx, entries) = create_genesis_tx(
292            vec![None],
293            vec![
294                OutputConfig { value: 100, authorizing_input: 0, covenant_group: 1 },
295                OutputConfig { value: 100, authorizing_input: 0, covenant_group: 1 },
296            ],
297            true,
298        );
299        let populated_tx = PopulatedTransaction::new(&tx, entries);
300        let ctx = CovenantsContext::from_tx(&populated_tx).unwrap();
301
302        // Genesis contexts empty
303        assert!(ctx.input_ctxs.is_empty());
304        assert!(ctx.shared_ctxs.is_empty());
305    }
306
307    #[test]
308    fn test_genesis_invalid_covenant_id() {
309        let (tx, entries) = create_genesis_tx(
310            vec![None],
311            vec![OutputConfig { value: 100, authorizing_input: 0, covenant_group: 1 }],
312            false, // Use wrong covenant ids
313        );
314        let populated_tx = PopulatedTransaction::new(&tx, entries);
315        let result = CovenantsContext::from_tx(&populated_tx);
316        assert!(matches!(result, Err(CovenantsError::WrongGenesisCovenantId(0, _))));
317    }
318
319    #[test]
320    fn test_genesis_single_input_multiple_covenant_groups() {
321        // Three outputs with two different covenant groups from the same input
322        let (tx, entries) = create_genesis_tx(
323            vec![None],
324            vec![
325                OutputConfig { value: 100, authorizing_input: 0, covenant_group: 1 }, // Group A
326                OutputConfig { value: 100, authorizing_input: 0, covenant_group: 2 }, // Group B
327                OutputConfig { value: 100, authorizing_input: 0, covenant_group: 1 }, // Group A
328            ],
329            true,
330        );
331        let populated_tx = PopulatedTransaction::new(&tx, entries);
332        let ctx = CovenantsContext::from_tx(&populated_tx).unwrap();
333
334        // Genesis contexts empty
335        assert!(ctx.input_ctxs.is_empty());
336        assert!(ctx.shared_ctxs.is_empty());
337    }
338
339    #[test]
340    fn test_genesis_multiple_inputs() {
341        let (tx, entries) = create_genesis_tx(
342            vec![None, None],
343            vec![
344                OutputConfig { value: 100, authorizing_input: 0, covenant_group: 1 },
345                OutputConfig { value: 100, authorizing_input: 1, covenant_group: 2 },
346            ],
347            true,
348        );
349        let populated_tx = PopulatedTransaction::new(&tx, entries);
350        let ctx = CovenantsContext::from_tx(&populated_tx).unwrap();
351
352        // Genesis contexts empty
353        assert!(ctx.input_ctxs.is_empty());
354        assert!(ctx.shared_ctxs.is_empty());
355    }
356
357    #[test]
358    fn test_continuation_with_genesis() {
359        // Complex case: 1 input with covenant id 42, creating:
360        // - 1 continuation output (covenant 42)
361        // - 2 genesis outputs (group A, non-contiguous at indices 1 and 3)
362        // - 1 genesis output (group B, at index 2)
363        // This tests a single input both continuing its covenant and creating new genesis covenants
364        let (tx, entries) = create_genesis_tx(
365            vec![Some(42)],
366            vec![
367                OutputConfig { value: 100, authorizing_input: 0, covenant_group: 42 }, // Continuation
368                OutputConfig { value: 100, authorizing_input: 0, covenant_group: 100 }, // Genesis A
369                OutputConfig { value: 100, authorizing_input: 0, covenant_group: 200 }, // Genesis B
370                OutputConfig { value: 100, authorizing_input: 0, covenant_group: 100 }, // Genesis A
371            ],
372            true,
373        );
374        let populated_tx = PopulatedTransaction::new(&tx, entries);
375        let actual_ctx = CovenantsContext::from_tx(&populated_tx).unwrap();
376
377        // Build expected context: input 0 continues covenant 42 with output 0
378        // Genesis outputs (1, 2, 3) should not appear in contexts
379        let covenant_42: Hash = 42u64.into();
380        let expected_ctx = CovenantsContext {
381            input_ctxs: HashMap::from_iter([(0, CovenantInputContext { auth_outputs: vec![0] })]),
382            shared_ctxs: HashMap::from_iter([(
383                covenant_42,
384                CovenantSharedContext { input_indices: vec![0], output_indices: vec![0] },
385            )]),
386        };
387
388        assert_eq!(actual_ctx, expected_ctx);
389    }
390
391    #[test]
392    fn test_authorizing_input_out_of_bounds() {
393        // Create a transaction with an output referencing a non-existent input
394        let input = TransactionInput::new(TransactionOutpoint::new(1u64.into(), 0), vec![], 0, 0);
395        let utxo = UtxoEntry {
396            amount: 1000,
397            script_public_key: Default::default(),
398            block_daa_score: 0,
399            is_coinbase: false,
400            covenant_id: None,
401        };
402
403        let output = TransactionOutput {
404            value: 100,
405            script_public_key: Default::default(),
406            covenant: Some(CovenantBinding {
407                covenant_id: 1u64.into(),
408                authorizing_input: 1, // Out of bounds - only 1 input (index 0)
409            }),
410        };
411
412        let mut tx = Transaction::new(0, vec![input], vec![output], 0, SubnetworkId::default(), 0, vec![]);
413        tx.finalize();
414
415        let populated_tx = PopulatedTransaction::new(&tx, vec![utxo]);
416        let result = CovenantsContext::from_tx(&populated_tx);
417        assert!(matches!(result, Err(CovenantsError::AuthInputOutOfBounds(0, 1))));
418    }
419
420    #[test]
421    fn test_no_covenant_outputs() {
422        // Create a transaction with outputs that have no covenants
423        let input = TransactionInput::new(TransactionOutpoint::new(1u64.into(), 0), vec![], 0, 0);
424        let utxo = UtxoEntry {
425            amount: 1000,
426            script_public_key: Default::default(),
427            block_daa_score: 0,
428            is_coinbase: false,
429            covenant_id: Some(42u64.into()),
430        };
431
432        // Outputs with no covenant bindings
433        let output1 = TransactionOutput::new(100, Default::default());
434        let output2 = TransactionOutput::new(200, Default::default());
435
436        let mut tx = Transaction::new(0, vec![input], vec![output1, output2], 0, SubnetworkId::default(), 0, vec![]);
437        tx.finalize();
438
439        let populated_tx = PopulatedTransaction::new(&tx, vec![utxo]);
440        let actual_ctx = CovenantsContext::from_tx(&populated_tx).unwrap();
441
442        // Input 0 has covenant_id, so shared context contains it
443        // No outputs have covenants, so input_ctxs is empty and shared context has no output indices
444        let covenant_42: Hash = 42u64.into();
445        let expected_ctx = CovenantsContext {
446            input_ctxs: HashMap::new(),
447            shared_ctxs: HashMap::from_iter([(covenant_42, CovenantSharedContext { input_indices: vec![0], output_indices: vec![] })]),
448        };
449
450        assert_eq!(actual_ctx, expected_ctx);
451    }
452}