Skip to main content

csv_adapter_core/
commitment.rs

1//! Commitment type with canonical encoding (MPC-aware, multi-protocol)
2//!
3//! Commitments bind off-chain state transitions to the anchoring layer.
4//! Each commitment is a node in a **commitment chain** — a sequence of
5//! linked state transitions that clients validate independently of the blockchain.
6//!
7//! ## Stability Guarantee
8//!
9//! **Only V2 is supported.** V1 was removed to prevent silent divergence
10//! between clients. All commitments must use the V2 format. This format
11//! will not change without a version bump and backward-compatible migration.
12
13use alloc::vec::Vec;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16
17use crate::hash::Hash;
18use crate::mpc::MpcTree;
19use crate::seal::SealRef;
20use crate::tagged_hash::csv_tagged_hash;
21
22/// Current commitment format version.
23///
24/// This constant is the authoritative version number. Any commitment
25/// with `version != COMMITMENT_VERSION` should be rejected.
26pub const COMMITMENT_VERSION: u8 = 2;
27
28/// A V2 commitment binding state to an anchor.
29///
30/// A commitment is the core data structure in CSV. It captures everything
31/// needed to verify a state transition:
32///
33/// - **Which protocol** it belongs to (`protocol_id`)
34/// - **What state** it represents (`mpc_root`, `contract_id`)
35/// - **Where it came from** (`previous_commitment`)
36/// - **What changed** (`transition_payload_hash`)
37/// - **What seal was consumed** (`seal_id`)
38/// - **Which chain context** it's valid in (`domain_separator`)
39///
40/// ## Commitment Chain
41///
42/// Commitments form a linked chain: each commitment references the hash
43/// of the previous one via `previous_commitment`. Clients validate the
44/// entire chain from genesis to the current state without querying the
45/// blockchain for each step.
46///
47/// ## Collision Resistance
48///
49/// Each field is hashed with a unique domain tag (e.g., `"commitment-version"`,
50/// `"commitment-protocol-id"`) using [`csv_tagged_hash`]. This prevents
51/// cross-field and cross-protocol collision attacks where an attacker could
52/// rearrange field bytes to forge a valid-looking commitment.
53#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
54pub struct Commitment {
55    /// Format version. Currently always [`COMMITMENT_VERSION`].
56    pub version: u8,
57
58    /// Protocol identifier this commitment belongs to.
59    ///
60    /// This is a 32-byte namespace that isolates commitments from different
61    /// protocols sharing the same anchoring layer. For example:
62    /// - Bitcoin adapter: `b"CSV-BTC-\x00\x00..."` (magic bytes padded)
63    /// - Ethereum adapter: `b"CSV-ETH-\x00\x00..."`
64    pub protocol_id: [u8; 32],
65
66    /// Merkle root of the MPC tree for multi-protocol witnessing.
67    ///
68    /// When multiple protocols share a single witness (e.g., a threshold
69    /// signature spanning Bitcoin and Ethereum), this root commits to
70    /// all participating protocols' individual roots.
71    ///
72    /// For single-protocol use (the common case), this is set to a
73    /// deterministic empty root: `SHA256("csv-empty-mpc-root")`.
74    pub mpc_root: Hash,
75
76    /// Unique contract/right identifier.
77    ///
78    /// For NFTs, this is the token ID. For credentials, this is the
79    /// credential hash. For assets, this is the asset identifier.
80    ///
81    /// Must be unique within the protocol namespace.
82    pub contract_id: Hash,
83
84    /// Hash of the previous commitment in the chain.
85    ///
86    /// For the first commitment (genesis), this is typically `Hash::zero()`.
87    /// For subsequent commitments, this is `previous_commitment.hash()`.
88    ///
89    /// This forms the commitment chain that clients validate independently.
90    pub previous_commitment: Hash,
91
92    /// SHA-256 hash of the state transition payload.
93    ///
94    /// This commits to the actual data being transitioned (e.g., new owner,
95    /// metadata update, transfer details). Clients must verify this hash
96    /// matches the payload they expect.
97    pub transition_payload_hash: Hash,
98
99    /// SHA-256 hash of the consumed seal reference.
100    ///
101    /// This binds the commitment to the specific seal that was consumed
102    /// to authorize this transition. The seal reference includes the
103    /// chain-specific identifier (UTXO txid, object ID, etc.) and the
104    /// seal's nonce/value.
105    pub seal_id: Hash,
106
107    /// Domain separator for chain-specific commitment isolation.
108    ///
109    /// Prevents cross-chain replay attacks by ensuring a commitment
110    /// created on one chain cannot be used on another. Typically
111    /// constructed as `H(chain_id || network || protocol_version)`.
112    pub domain_separator: [u8; 32],
113}
114
115impl Commitment {
116    /// Create a commitment
117    ///
118    /// This creates a V2 (MPC-aware) commitment. All adapters should use this
119    /// constructor. The `protocol_id` should uniquely identify the protocol
120    /// (e.g., "CSV-BTC-" for Bitcoin, "CSV-ETH-" for Ethereum).
121    pub fn new(
122        protocol_id: [u8; 32],
123        mpc_tree: &MpcTree,
124        contract_id: Hash,
125        previous_commitment: Hash,
126        transition_payload_hash: Hash,
127        seal_ref: &SealRef,
128        domain_separator: [u8; 32],
129    ) -> Self {
130        let seal_hash = {
131            let mut hasher = Sha256::new();
132            hasher.update(seal_ref.to_vec());
133            let result = hasher.finalize();
134            let mut array = [0u8; 32];
135            array.copy_from_slice(&result);
136            Hash::new(array)
137        };
138
139        let mpc_root = mpc_tree.root();
140
141        Self {
142            version: COMMITMENT_VERSION,
143            protocol_id,
144            mpc_root,
145            contract_id,
146            previous_commitment,
147            transition_payload_hash,
148            seal_id: seal_hash,
149            domain_separator,
150        }
151    }
152
153    /// Create a commitment without MPC tree (for simple single-protocol use)
154    ///
155    /// This is a convenience constructor that uses a default empty MPC root.
156    /// For multi-protocol use cases, use [`Commitment::new`] instead.
157    pub fn simple(
158        contract_id: Hash,
159        previous_commitment: Hash,
160        transition_payload_hash: Hash,
161        seal_ref: &SealRef,
162        domain_separator: [u8; 32],
163    ) -> Self {
164        let seal_hash = {
165            let mut hasher = Sha256::new();
166            hasher.update(seal_ref.to_vec());
167            let result = hasher.finalize();
168            let mut array = [0u8; 32];
169            array.copy_from_slice(&result);
170            Hash::new(array)
171        };
172
173        // Extract protocol_id from domain separator (first 4 bytes)
174        let mut protocol_id = [0u8; 32];
175        protocol_id[..4].copy_from_slice(&domain_separator[..4]);
176
177        // Use empty MPC root for single-protocol mode
178        let mpc_root = {
179            let mut hasher = Sha256::new();
180            hasher.update(b"csv-empty-mpc-root");
181            let result = hasher.finalize();
182            let mut array = [0u8; 32];
183            array.copy_from_slice(&result);
184            Hash::new(array)
185        };
186
187        Self {
188            version: COMMITMENT_VERSION,
189            protocol_id,
190            mpc_root,
191            contract_id,
192            previous_commitment,
193            transition_payload_hash,
194            seal_id: seal_hash,
195            domain_separator,
196        }
197    }
198
199    /// Backwards-compatible alias for [`Commitment::simple`].
200    #[deprecated(since = "0.2.0", note = "Use `Commitment::simple` instead")]
201    pub fn v1(
202        contract_id: Hash,
203        previous_commitment: Hash,
204        transition_payload_hash: Hash,
205        seal_ref: &SealRef,
206        domain_separator: [u8; 32],
207    ) -> Self {
208        Self::simple(
209            contract_id,
210            previous_commitment,
211            transition_payload_hash,
212            seal_ref,
213            domain_separator,
214        )
215    }
216
217    /// Compute the commitment hash
218    pub fn hash(&self) -> Hash {
219        let mut hasher = Sha256::new();
220        self.hash_into(&mut hasher);
221        let result = hasher.finalize();
222        let mut array = [0u8; 32];
223        array.copy_from_slice(&result);
224        Hash::new(array)
225    }
226
227    fn hash_into(&self, hasher: &mut Sha256) {
228        // Use tagged hashing for each field to prevent cross-protocol collisions
229        hasher.update(csv_tagged_hash("commitment-version", &[self.version]));
230        hasher.update(csv_tagged_hash("commitment-protocol-id", &self.protocol_id));
231        hasher.update(csv_tagged_hash(
232            "commitment-mpc-root",
233            self.mpc_root.as_bytes(),
234        ));
235        hasher.update(csv_tagged_hash(
236            "commitment-contract-id",
237            self.contract_id.as_bytes(),
238        ));
239        hasher.update(csv_tagged_hash(
240            "commitment-prev",
241            self.previous_commitment.as_bytes(),
242        ));
243        hasher.update(csv_tagged_hash(
244            "commitment-payload",
245            self.transition_payload_hash.as_bytes(),
246        ));
247        hasher.update(csv_tagged_hash("commitment-seal", self.seal_id.as_bytes()));
248        hasher.update(csv_tagged_hash("commitment-domain", &self.domain_separator));
249    }
250
251    /// Get the version
252    pub fn version(&self) -> u8 {
253        self.version
254    }
255
256    /// Get the contract ID
257    pub fn contract_id(&self) -> Hash {
258        self.contract_id
259    }
260
261    /// Get the seal ID hash
262    pub fn seal_id(&self) -> Hash {
263        self.seal_id
264    }
265
266    /// Get the domain separator
267    pub fn domain_separator(&self) -> [u8; 32] {
268        self.domain_separator
269    }
270
271    /// Serialize commitment using canonical encoding
272    pub fn to_canonical_bytes(&self) -> Vec<u8> {
273        let mut out = Vec::with_capacity(1 + 32 * 7);
274        out.push(self.version);
275        out.extend_from_slice(&self.protocol_id);
276        out.extend_from_slice(self.mpc_root.as_bytes());
277        out.extend_from_slice(self.contract_id.as_bytes());
278        out.extend_from_slice(self.previous_commitment.as_bytes());
279        out.extend_from_slice(self.transition_payload_hash.as_bytes());
280        out.extend_from_slice(self.seal_id.as_bytes());
281        out.extend_from_slice(&self.domain_separator);
282        out
283    }
284
285    /// Deserialize commitment from canonical bytes
286    ///
287    /// **Only V2 format is supported.** Legacy V1 format was removed.
288    pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, &'static str> {
289        if bytes.is_empty() {
290            return Err("Empty commitment bytes");
291        }
292
293        let version = bytes[0];
294        if version != COMMITMENT_VERSION {
295            return Err("Unsupported commitment version");
296        }
297
298        // V2 format: version(1) + protocol_id(32) + mpc_root(32) + contract_id(32) +
299        //             previous_commitment(32) + payload_hash(32) + seal_id(32) + domain_separator(32)
300        let min_len = 1 + 32 * 7;
301        if bytes.len() < min_len {
302            return Err("Commitment bytes too short");
303        }
304
305        let mut protocol_id = [0u8; 32];
306        protocol_id.copy_from_slice(&bytes[1..33]);
307        let mut mpc_root = [0u8; 32];
308        mpc_root.copy_from_slice(&bytes[33..65]);
309        let mut contract_id = [0u8; 32];
310        contract_id.copy_from_slice(&bytes[65..97]);
311        let mut previous_commitment = [0u8; 32];
312        previous_commitment.copy_from_slice(&bytes[97..129]);
313        let mut transition_payload_hash = [0u8; 32];
314        transition_payload_hash.copy_from_slice(&bytes[129..161]);
315        let mut seal_id = [0u8; 32];
316        seal_id.copy_from_slice(&bytes[161..193]);
317        let mut domain_separator = [0u8; 32];
318        domain_separator.copy_from_slice(&bytes[193..225]);
319
320        Ok(Self {
321            version,
322            protocol_id,
323            mpc_root: Hash::new(mpc_root),
324            contract_id: Hash::new(contract_id),
325            previous_commitment: Hash::new(previous_commitment),
326            transition_payload_hash: Hash::new(transition_payload_hash),
327            seal_id: Hash::new(seal_id),
328            domain_separator,
329        })
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::mpc::MpcTree;
337
338    fn test_commitment_commitment() -> Commitment {
339        Commitment::simple(
340            Hash::new([1u8; 32]),
341            Hash::new([2u8; 32]),
342            Hash::new([3u8; 32]),
343            &SealRef::new(vec![4u8; 16], Some(42)).unwrap(),
344            [5u8; 32],
345        )
346    }
347
348    fn test_mpc_commitment() -> Commitment {
349        let protocol_id = [10u8; 32];
350        let mpc_tree = MpcTree::from_pairs(&[
351            (protocol_id, Hash::new([20u8; 32])),
352            ([20u8; 32], Hash::new([30u8; 32])),
353        ]);
354        Commitment::new(
355            protocol_id,
356            &mpc_tree,
357            Hash::new([1u8; 32]),
358            Hash::new([2u8; 32]),
359            Hash::new([3u8; 32]),
360            &SealRef::new(vec![4u8; 16], Some(42)).unwrap(),
361            [5u8; 32],
362        )
363    }
364
365    // ─────────────────────────────────────────────
366    // Basic commitment tests
367    // ─────────────────────────────────────────────
368
369    #[test]
370    fn test_commitment_creation() {
371        let c = test_commitment_commitment();
372        assert_eq!(c.version(), COMMITMENT_VERSION);
373    }
374
375    #[test]
376    fn test_commitment_hash_deterministic() {
377        let c1 = test_commitment_commitment();
378        let c2 = test_commitment_commitment();
379        assert_eq!(c1.hash(), c2.hash());
380    }
381
382    #[test]
383    fn test_commitment_canonical_roundtrip() {
384        let c = test_commitment_commitment();
385        let bytes = c.to_canonical_bytes();
386        let restored = Commitment::from_canonical_bytes(&bytes).unwrap();
387        assert_eq!(c.hash(), restored.hash());
388    }
389
390    // ─────────────────────────────────────────────
391    // MPC-aware commitment tests
392    // ─────────────────────────────────────────────
393
394    #[test]
395    fn test_mpc_creation() {
396        let c = test_mpc_commitment();
397        assert_eq!(c.version(), COMMITMENT_VERSION);
398    }
399
400    #[test]
401    fn test_mpc_hash_deterministic() {
402        let c1 = test_mpc_commitment();
403        let c2 = test_mpc_commitment();
404        assert_eq!(c1.hash(), c2.hash());
405    }
406
407    #[test]
408    fn test_mpc_canonical_roundtrip() {
409        let c = test_mpc_commitment();
410        let bytes = c.to_canonical_bytes();
411        let restored = Commitment::from_canonical_bytes(&bytes).unwrap();
412        assert_eq!(c.hash(), restored.hash());
413    }
414
415    #[test]
416    fn test_mpc_contains_mpc_root() {
417        let protocol_id = [10u8; 32];
418        let mpc_tree = MpcTree::from_pairs(&[
419            (protocol_id, Hash::new([20u8; 32])),
420            ([20u8; 32], Hash::new([30u8; 32])),
421        ]);
422        let _expected_root = mpc_tree.root();
423
424        let seal = SealRef::new(vec![4u8; 16], Some(42)).unwrap();
425        let c = Commitment::new(
426            protocol_id,
427            &mpc_tree,
428            Hash::new([1u8; 32]),
429            Hash::new([2u8; 32]),
430            Hash::new([3u8; 32]),
431            &seal,
432            [5u8; 32],
433        );
434
435        // The commitment hash should differ from a commitment without this MPC root
436        let different_tree = MpcTree::from_pairs(&[(protocol_id, Hash::new([99u8; 32]))]);
437        let c_different = Commitment::new(
438            protocol_id,
439            &different_tree,
440            Hash::new([1u8; 32]),
441            Hash::new([2u8; 32]),
442            Hash::new([3u8; 32]),
443            &seal,
444            [5u8; 32],
445        );
446
447        assert_ne!(c.hash(), c_different.hash());
448    }
449
450    #[test]
451    fn test_mpc_differs_by_protocol_id() {
452        let mpc_tree = MpcTree::from_pairs(&[([10u8; 32], Hash::new([20u8; 32]))]);
453        let seal = SealRef::new(vec![4u8; 16], Some(42)).unwrap();
454
455        let c1 = Commitment::new(
456            [10u8; 32],
457            &mpc_tree,
458            Hash::new([1u8; 32]),
459            Hash::new([2u8; 32]),
460            Hash::new([3u8; 32]),
461            &seal,
462            [5u8; 32],
463        );
464
465        let c2 = Commitment::new(
466            [11u8; 32],
467            &mpc_tree,
468            Hash::new([1u8; 32]),
469            Hash::new([2u8; 32]),
470            Hash::new([3u8; 32]),
471            &seal,
472            [5u8; 32],
473        );
474
475        assert_ne!(c1.hash(), c2.hash());
476    }
477
478    // ─────────────────────────────────────────────
479    // Version interop tests
480    // ─────────────────────────────────────────────
481
482    #[test]
483    fn test_commitment_v2_different_hashes() {
484        let v1 = test_commitment_commitment();
485        let v2 = test_mpc_commitment();
486        assert_ne!(v1.hash(), v2.hash());
487    }
488
489    #[test]
490    fn test_commitment_v2_same_contract_different_versions() {
491        let v1 = test_commitment_commitment();
492        let v2 = test_mpc_commitment();
493        // Both reference same contract ID
494        assert_eq!(v1.contract_id(), v2.contract_id());
495        // But different structure → different hashes
496        assert_ne!(v1.hash(), v2.hash());
497    }
498
499    // ─────────────────────────────────────────────
500    // Accessor tests
501    // ─────────────────────────────────────────────
502
503    #[test]
504    fn test_commitment_accessors() {
505        let v1 = test_commitment_commitment();
506        assert_eq!(v1.contract_id(), Hash::new([1u8; 32]));
507        assert_eq!(v1.domain_separator(), [5u8; 32]);
508
509        let v2 = test_mpc_commitment();
510        assert_eq!(v2.contract_id(), Hash::new([1u8; 32]));
511        assert_eq!(v2.domain_separator(), [5u8; 32]);
512    }
513
514    // ─────────────────────────────────────────────
515    // Deserialization error tests
516    // ─────────────────────────────────────────────
517
518    #[test]
519    fn test_from_bytes_empty() {
520        assert!(Commitment::from_canonical_bytes(&[]).is_err());
521    }
522
523    #[test]
524    fn test_from_bytes_unknown_version() {
525        let mut bytes = vec![99u8];
526        bytes.resize(225, 0);
527        assert!(Commitment::from_canonical_bytes(&bytes).is_err());
528    }
529
530    #[test]
531    fn test_from_bytes_unsupported_version() {
532        assert!(Commitment::from_canonical_bytes(&[1, 0, 0]).is_err()); // V1 no longer supported
533    }
534
535    #[test]
536    fn test_from_bytes_too_short() {
537        assert!(Commitment::from_canonical_bytes(&[2, 0, 0]).is_err());
538    }
539
540    // ─────────────────────────────────────────────
541    // MPC integration test
542    // ─────────────────────────────────────────────
543
544    #[test]
545    fn test_commitment_with_multi_protocol_mpc() {
546        // Simulate 3 protocols sharing one witness
547        let proto_a = [0xAA; 32];
548        let proto_b = [0xBB; 32];
549        let proto_c = [0xCC; 32];
550
551        let mpc_tree = MpcTree::from_pairs(&[
552            (proto_a, Hash::new([1u8; 32])),
553            (proto_b, Hash::new([2u8; 32])),
554            (proto_c, Hash::new([3u8; 32])),
555        ]);
556
557        // Protocol A's commitment
558        let seal = SealRef::new(vec![0xDD; 16], Some(1)).unwrap();
559        let commitment_a = Commitment::new(
560            proto_a,
561            &mpc_tree,
562            Hash::new([10u8; 32]),
563            Hash::zero(),
564            Hash::new([11u8; 32]),
565            &seal,
566            [0xEE; 32],
567        );
568
569        // Verify the MPC root in the commitment matches the tree root
570        assert_eq!(commitment_a.mpc_root, mpc_tree.root());
571
572        // Generate MPC proof for protocol A
573        let proof = mpc_tree.prove(proto_a).unwrap();
574        assert!(proof.verify(&mpc_tree.root()));
575    }
576}