clementine_tx_sender/citrea/
reveal_scripts.rs

1//! This module contains functions to create transactions for the DA layer.
2
3use bitcoin::blockdata::opcodes::all::{OP_CHECKSIGVERIFY, OP_ENDIF, OP_IF, OP_NIP};
4use bitcoin::blockdata::opcodes::OP_FALSE;
5use bitcoin::blockdata::script;
6use bitcoin::script::PushBytesBuf;
7use bitcoin::secp256k1::{All, Secp256k1};
8use bitcoin::taproot::{ControlBlock, LeafVersion, TaprootBuilder};
9use bitcoin::{Address, Network, ScriptBuf, XOnlyPublicKey};
10
11use crate::citrea::TransactionKind;
12use crate::signer::TxSenderSigningKey;
13use crate::TxSender;
14
15/// Bundle of data required for committing and later revealing a Citrea payload.
16#[derive(Debug, Clone)]
17pub struct CitreaSigningData {
18    pub reveal_script: ScriptBuf,
19    pub control_block: ControlBlock,
20    pub commit_address: Address,
21}
22
23impl TxSender {
24    /// Creates a reveal script for a Citrea transaction based on transaction kind and body.
25    ///
26    /// The script structure follows the commit-reveal pattern:
27    /// - public_key OP_CHECKSIGVERIFY (verifies the reveal key)
28    /// - transaction_kind (2 bytes)
29    /// - OP_FALSE OP_IF (start data push)
30    /// - [signature and signer_public_key for Complete, SequencerCommitment, Aggregate]
31    /// - body (pushed in 520-byte chunks)
32    /// - OP_ENDIF
33    /// - nonce (fixed to 16) OP_NIP
34    ///
35    /// # Arguments
36    /// * `transaction_kind` - The type of Citrea transaction
37    /// * `body` - The transaction body bytes
38    ///
39    /// # Returns
40    /// A tuple containing:
41    /// - The constructed reveal script
42    /// - The control block for spending the taproot output
43    /// - The commit transaction address (P2TR)
44    pub fn create_reveal_script(
45        &self,
46        transaction_kind: TransactionKind,
47        body: &[u8],
48    ) -> CitreaSigningData {
49        create_reveal_script(
50            self.xonly_public_key(),
51            &self.da_signer,
52            self.network,
53            transaction_kind,
54            body,
55        )
56    }
57}
58
59fn create_reveal_script(
60    public_key: XOnlyPublicKey,
61    da_signer: &TxSenderSigningKey,
62    network: Network,
63    transaction_kind: TransactionKind,
64    body: &[u8],
65) -> CitreaSigningData {
66    let kind_bytes = transaction_kind.to_bytes();
67
68    // Nonce is fixed to 16
69    let nonce: i64 = 16;
70
71    // Determine if this transaction kind requires signature and signer_public_key
72    let needs_signature = matches!(
73        transaction_kind,
74        TransactionKind::Complete
75            | TransactionKind::SequencerCommitment
76            | TransactionKind::Aggregate
77    );
78
79    let mut reveal_script_builder = script::Builder::new()
80        .push_x_only_key(&public_key)
81        .push_opcode(OP_CHECKSIGVERIFY)
82        .push_slice(PushBytesBuf::from(kind_bytes))
83        .push_opcode(OP_FALSE)
84        .push_opcode(OP_IF);
85
86    // Add signature and signer_public_key for transaction kinds that require authentication
87    if needs_signature {
88        let (signature, signer_public_key) = da_signer.sign_blob(body);
89        reveal_script_builder = reveal_script_builder
90            .push_slice(PushBytesBuf::try_from(signature).expect("Cannot push signature"))
91            .push_slice(
92                PushBytesBuf::try_from(signer_public_key).expect("Cannot push signer public key"),
93            );
94    }
95
96    // Push body in chunks of 520 bytes
97    for chunk in body.chunks(520) {
98        reveal_script_builder = reveal_script_builder
99            .push_slice(PushBytesBuf::try_from(chunk.to_vec()).expect("Cannot push body chunk"));
100    }
101
102    // Push end if, nonce, and NIP
103    reveal_script_builder = reveal_script_builder
104        .push_opcode(OP_ENDIF)
105        .push_slice(nonce.to_le_bytes())
106        .push_opcode(OP_NIP);
107
108    let reveal_script = reveal_script_builder.into_script();
109
110    // Build control block and address
111    let secp = Secp256k1::<All>::new();
112    let taproot_spend_info = TaprootBuilder::new()
113        .add_leaf(0, reveal_script.clone())
114        .expect("Cannot add reveal script to taptree")
115        .finalize(&secp, public_key)
116        .expect("Cannot finalize taptree");
117
118    let control_block = taproot_spend_info
119        .control_block(&(reveal_script.clone(), LeafVersion::TapScript))
120        .expect("Cannot create control block");
121
122    let merkle_root = taproot_spend_info.merkle_root();
123    let commit_address = Address::p2tr(&secp, public_key, merkle_root, network);
124
125    CitreaSigningData {
126        reveal_script,
127        control_block,
128        commit_address,
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use bitcoin::absolute::LockTime;
135    use bitcoin::hashes::Hash as _;
136    use bitcoin::secp256k1::SecretKey;
137    use bitcoin::transaction::Version;
138    use bitcoin::{
139        Network, OutPoint, ScriptBuf, Sequence, Transaction, TxIn, TxOut, Txid, Witness,
140    };
141    use clementine_primitives::MIN_TAPROOT_AMOUNT;
142
143    use crate::citrea::data_serialization::DataOnDa;
144    use crate::citrea::{TransactionKind, MAX_CHUNK_SIZE};
145    use crate::signer::TxSenderSigningKey;
146
147    use super::create_reveal_script;
148
149    const TAPROOT_INPUT_COUNT: usize = 10;
150    const TAPROOT_OUTPUT_COUNT: usize = 2;
151    const STANDARD_TX_MAX_VBYTES: u64 = 100_000;
152
153    #[test]
154    fn max_chunk_reveal_transaction_stays_under_standard_weight() {
155        let secret_key = SecretKey::from_slice(&[1u8; 32]).expect("valid test secret key");
156        let signer = TxSenderSigningKey::new(secret_key, Network::Regtest);
157
158        let raw_chunk = vec![0u8; MAX_CHUNK_SIZE as usize];
159        let reveal_body =
160            borsh::to_vec(&DataOnDa::Chunk(raw_chunk)).expect("max chunk DA body must serialize");
161        assert!(
162            reveal_body.len() > MAX_CHUNK_SIZE as usize,
163            "test must cover the final Borsh-wrapped reveal body"
164        );
165
166        let signing_data = create_reveal_script(
167            signer.xonly_public_key(),
168            &signer,
169            Network::Regtest,
170            TransactionKind::Chunks,
171            &reveal_body,
172        );
173
174        let mut inputs: Vec<TxIn> = (0..TAPROOT_INPUT_COUNT)
175            .map(|vout| TxIn {
176                previous_output: OutPoint {
177                    txid: Txid::all_zeros(),
178                    vout: vout as u32,
179                },
180                script_sig: ScriptBuf::new(),
181                sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
182                witness: Witness::new(),
183            })
184            .collect();
185
186        let mut reveal_witness = Witness::new();
187        reveal_witness.push([0u8; 65]);
188        reveal_witness.push(signing_data.reveal_script);
189        reveal_witness.push(signing_data.control_block.serialize());
190        inputs[0].witness = reveal_witness;
191
192        for input in inputs.iter_mut().skip(1) {
193            input.witness.push([0u8; 65]);
194        }
195
196        let script_pubkey = signer.address().script_pubkey();
197        let outputs = (0..TAPROOT_OUTPUT_COUNT)
198            .map(|_| TxOut {
199                value: MIN_TAPROOT_AMOUNT,
200                script_pubkey: script_pubkey.clone(),
201            })
202            .collect();
203
204        let reveal_tx = Transaction {
205            version: Version::TWO,
206            lock_time: LockTime::ZERO,
207            input: inputs,
208            output: outputs,
209        };
210
211        let weight = reveal_tx.weight().to_wu();
212        let vbytes = reveal_tx.weight().to_vbytes_ceil();
213        println!("weight: {weight}, vbytes: {vbytes}");
214        assert!(
215            vbytes < STANDARD_TX_MAX_VBYTES,
216            "max chunk reveal transaction is {weight} WU / {vbytes} vB"
217        );
218    }
219}