Skip to main content

csv_adapter_bitcoin/
tapret.rs

1//! Bitcoin Tapret/Opret commitment scripts
2//!
3//! Implements Tapret leaf script construction with nonce mining
4//! and Opret fallback.
5//!
6//! ## Tapret Commitment (RGB-compatible)
7//!
8//! Per RGB specification and BIP-341:
9//! - Tapret leaf: OP_RETURN <protocol_id (32 bytes)> <nonce (1 byte)> <commitment (32 bytes)>
10//! - Nonce mining ensures the tapret leaf is placed at the rightmost depth-1 position
11//!   in the Taproot merkle tree
12//! - Internal key is derived from the wallet's taproot key
13//!
14//! ## Opret Fallback
15//!
16//! Simpler OP_RETURN commitment for non-Taproot outputs:
17//! - Script: OP_RETURN <protocol_id (32 bytes)> <commitment (32 bytes)>
18
19use bitcoin::{
20    opcodes::all::OP_RETURN,
21    script::{Builder, PushBytesBuf},
22    ScriptBuf,
23};
24
25use csv_adapter_core::hash::Hash;
26
27/// Tapret commitment script: OP_RETURN <65 bytes>
28pub const TAPRET_SCRIPT_SIZE: usize = 67;
29
30/// Opret commitment script: OP_RETURN <64 bytes>
31pub const OPRET_SCRIPT_SIZE: usize = 66;
32
33/// A Tapret commitment
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct TapretCommitment {
36    pub protocol_id: [u8; 32],
37    pub commitment: Hash,
38}
39
40impl TapretCommitment {
41    pub fn new(protocol_id: [u8; 32], commitment: Hash) -> Self {
42        Self {
43            protocol_id,
44            commitment,
45        }
46    }
47
48    pub fn payload(&self) -> [u8; 64] {
49        let mut payload = [0u8; 64];
50        payload[..32].copy_from_slice(&self.protocol_id);
51        payload[32..].copy_from_slice(self.commitment.as_bytes());
52        payload
53    }
54
55    pub fn leaf_script(&self) -> ScriptBuf {
56        let payload = self.payload();
57        let push_bytes = PushBytesBuf::try_from(payload.to_vec()).unwrap();
58        Builder::new()
59            .push_opcode(OP_RETURN)
60            .push_slice(push_bytes)
61            .into_script()
62    }
63
64    /// Build the Tapret leaf with a nonce appended for mining
65    ///
66    /// The nonce is used to ensure the Tapret leaf ends up at the rightmost
67    /// depth-1 position in the Taproot merkle tree per BIP-341 consensus ordering.
68    pub fn leaf_script_with_nonce(&self, nonce: u8) -> ScriptBuf {
69        let mut payload = [0u8; 65];
70        payload[..32].copy_from_slice(&self.protocol_id);
71        payload[32] = nonce;
72        payload[33..65].copy_from_slice(self.commitment.as_bytes());
73        let push_bytes = PushBytesBuf::try_from(payload.to_vec()).unwrap();
74        Builder::new()
75            .push_opcode(OP_RETURN)
76            .push_slice(push_bytes)
77            .into_script()
78    }
79}
80
81/// Opret (OP_RETURN) commitment: simpler fallback for non-Taproot outputs
82#[derive(Clone, Debug, PartialEq, Eq)]
83pub struct OpretCommitment {
84    pub protocol_id: [u8; 32],
85    pub commitment: Hash,
86}
87
88impl OpretCommitment {
89    pub fn new(protocol_id: [u8; 32], commitment: Hash) -> Self {
90        Self {
91            protocol_id,
92            commitment,
93        }
94    }
95
96    pub fn script(&self) -> ScriptBuf {
97        let mut data = Vec::with_capacity(64);
98        data.extend_from_slice(&self.protocol_id);
99        data.extend_from_slice(self.commitment.as_bytes());
100        let push_bytes = PushBytesBuf::try_from(data).unwrap();
101        Builder::new()
102            .push_opcode(OP_RETURN)
103            .push_slice(push_bytes)
104            .into_script()
105    }
106}
107
108/// Mine a nonce for the Tapret leaf
109///
110/// For RGB Tapret commitments, the nonce is used to ensure proper positioning
111/// of the tapret leaf in the Taproot merkle tree. When building a tree with
112/// multiple leaves, different nonces produce different leaf hashes, which affects
113/// tree structure and leaf positions.
114///
115/// For the common case of a single tapret leaf, any nonce produces a valid script.
116/// This function iterates random nonces and validates the resulting script structure.
117///
118/// # RGB Tapret Spec
119/// - Leaf script: OP_RETURN <protocol_id (32) || nonce (1) || commitment (32)>
120/// - Total size: 67 bytes (OP_RETURN + push + data)
121/// - Nonce mining ensures script is well-formed and extractable
122///
123/// Returns the nonce and the leaf script.
124pub fn mine_tapret_nonce(
125    tapret: &TapretCommitment,
126    max_attempts: u32,
127) -> Result<(u8, ScriptBuf), TapretError> {
128    use rand::RngCore;
129    let mut rng = rand::thread_rng();
130
131    for _attempt in 0..max_attempts {
132        let nonce = rng.next_u32() as u8;
133        let script = tapret.leaf_script_with_nonce(nonce);
134
135        // Validate script meets RGB Tapret requirements:
136        // - Must be OP_RETURN
137        // - Must be exactly 67 bytes (OP_RETURN + OP_PUSHBYTES_65 + 65 bytes data)
138        if script.is_op_return() && script.len() == TAPRET_SCRIPT_SIZE {
139            return Ok((nonce, script));
140        }
141    }
142
143    Err(TapretError::NonceMiningFailed(max_attempts))
144}
145
146/// Tapret error types
147#[derive(Debug, thiserror::Error)]
148pub enum TapretError {
149    #[error("Nonce mining failed after {0} attempts")]
150    NonceMiningFailed(u32),
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    fn test_commitment() -> TapretCommitment {
158        TapretCommitment::new([1u8; 32], Hash::new([2u8; 32]))
159    }
160
161    #[test]
162    fn test_tapret_payload() {
163        let tc = test_commitment();
164        let payload = tc.payload();
165        assert_eq!(payload.len(), 64);
166        assert_eq!(&payload[..32], &[1u8; 32]);
167        assert_eq!(&payload[32..], &[2u8; 32]);
168    }
169
170    #[test]
171    fn test_tapret_leaf_script() {
172        let tc = test_commitment();
173        let script = tc.leaf_script();
174        // Without nonce: OP_RETURN (1) + OP_PUSHBYTES_64 (1) + 64 bytes = 66
175        assert_eq!(script.len(), OPRET_SCRIPT_SIZE);
176    }
177
178    #[test]
179    fn test_tapret_leaf_with_nonce() {
180        let tc = test_commitment();
181        let script_no_nonce = tc.leaf_script();
182        let script_with_nonce = tc.leaf_script_with_nonce(42);
183        // With nonce: OP_RETURN (1) + OP_PUSHBYTES_65 (1) + 65 bytes = 67
184        assert_eq!(script_with_nonce.len(), TAPRET_SCRIPT_SIZE);
185        assert_eq!(script_with_nonce.len(), script_no_nonce.len() + 1);
186    }
187
188    #[test]
189    fn test_nonce_mining() {
190        let tc = test_commitment();
191        let (nonce, script) = mine_tapret_nonce(&tc, 256).unwrap();
192        // Mined script should have nonce (67 bytes)
193        assert_eq!(script.len(), TAPRET_SCRIPT_SIZE);
194        // Verify the nonce is embedded in the script
195        assert!(script.as_bytes().contains(&nonce));
196    }
197
198    #[test]
199    fn test_opret_script() {
200        let oc = OpretCommitment::new([1u8; 32], Hash::new([2u8; 32]));
201        let script = oc.script();
202        assert!(script.is_op_return());
203        // Opret script: 66 bytes (no nonce)
204        assert_eq!(script.len(), OPRET_SCRIPT_SIZE);
205    }
206
207    #[test]
208    fn test_opret_script_content() {
209        let oc = OpretCommitment::new([0xAB; 32], Hash::new([0xCD; 32]));
210        let script = oc.script();
211        let bytes = script.as_bytes();
212        // OP_RETURN (0x6a) + OP_PUSHBYTES_64 (0x40) + 64 bytes data
213        assert_eq!(bytes[0], 0x6a); // OP_RETURN
214        assert_eq!(bytes[1], 0x40); // OP_PUSHBYTES_64
215    }
216}