Skip to main content

csv_adapter_core/
right.rs

1//! The Universal Seal Primitive — Canonical Right Type
2//!
3//! A Right can be exercised at most once under the strongest available
4//! guarantee of the host chain. This is the core invariant of the entire system.
5//!
6//! ## Enforcement Layers
7//!
8//! | Level | Name | Chains | Mechanism |
9//! |-------|------|--------|-----------|
10//! | L1 | Structural | Bitcoin, Sui | Spend UTXO / Consume Object |
11//! | L2 | Type-Enforced | Aptos | Destroy Move Resource |
12//! | L3 | Cryptographic | Ethereum | Nullifier Registration |
13//!
14//! ## Client-Side Validation
15//!
16//! The chain does NOT validate state transitions. It only:
17//! 1. Records the commitment (anchor)
18//! 2. Enforces single-use of the Right
19//!
20//! Clients do everything else:
21//! 1. Fetch the full state history for a contract
22//! 2. Verify the commitment chain from genesis to present
23//! 3. Check that no Right was consumed more than once
24//! 4. Accept or reject the consignment based on local validation
25
26use alloc::vec::Vec;
27use serde::{Deserialize, Serialize};
28
29use crate::hash::Hash;
30use crate::tagged_hash::csv_tagged_hash;
31
32/// A unique Right identifier.
33///
34/// Computed as `H(commitment || salt)` to ensure uniqueness
35/// even when the same state is committed to multiple times.
36#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
37pub struct RightId(pub Hash);
38
39/// Proof of ownership for a Right.
40///
41/// On L1 chains (Bitcoin, Sui): this is the UTXO/Object ownership proof.
42/// On L2 chains (Aptos): this is the resource capability proof.
43/// On L3 chains (Ethereum): this is the signature from the owner.
44#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
45pub struct OwnershipProof {
46    /// The proof bytes (chain-specific format)
47    pub proof: Vec<u8>,
48    /// The owner identifier (address, pubkey, etc.)
49    pub owner: Vec<u8>,
50    /// Signature scheme for cryptographic verification.
51    /// Encodes which signature scheme the `proof` field uses.
52    pub scheme: Option<crate::signature::SignatureScheme>,
53}
54
55/// A consumable Right in the USP system.
56///
57/// Every chain enforces single-use of Rights, but at different
58/// enforcement levels (L1 Structural → L2 Type-Enforced → L3 Cryptographic).
59///
60/// The chain provides the minimum guarantee (single-use enforcement).
61/// Clients verify everything else.
62#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
63pub struct Right {
64    /// Unique identifier: `H(commitment || salt)`
65    pub id: RightId,
66    /// Encodes the state + rules of this Right
67    pub commitment: Hash,
68    /// Proof of ownership
69    pub owner: OwnershipProof,
70    /// Salt used to compute the Right ID.
71    /// Stored to enable ID recomputation during deserialization.
72    pub salt: Vec<u8>,
73    /// One-time consumption marker (L3+ only)
74    ///
75    /// L1 (Bitcoin/Sui): None — chain enforces structurally.
76    /// L2 (Aptos): None — Move VM enforces non-duplication.
77    /// L3 (Ethereum): Some — nullifier registered in contract.
78    pub nullifier: Option<Hash>,
79    /// Off-chain state commitment root
80    ///
81    /// Commits to the full state history for this Right.
82    /// Clients use this to verify state transitions without
83    /// fetching the entire history on every validation.
84    pub state_root: Option<Hash>,
85    /// Optional execution proof (ZK, fraud proof, etc.)
86    ///
87    /// For advanced use cases where the Right's execution
88    /// needs to be proven without revealing its contents.
89    pub execution_proof: Option<Vec<u8>>,
90}
91
92impl Right {
93    /// Create a new Right with the given parameters.
94    ///
95    /// The Right ID is deterministically computed from the commitment
96    /// and salt, ensuring uniqueness even for duplicate commitments.
97    pub fn new(commitment: Hash, owner: OwnershipProof, salt: &[u8]) -> Self {
98        let id = {
99            // Use tagged hash with domain separation for Right ID computation
100            let mut data = Vec::with_capacity(32 + salt.len());
101            data.extend_from_slice(commitment.as_bytes());
102            data.extend_from_slice(salt);
103            let result = csv_tagged_hash("right-id", &data);
104            RightId(Hash::new(result))
105        };
106
107        Self {
108            id,
109            commitment,
110            owner,
111            salt: salt.to_vec(),
112            nullifier: None,
113            state_root: None,
114            execution_proof: None,
115        }
116    }
117
118    /// Mark this Right as consumed by setting the nullifier.
119    ///
120    /// # Enforcement Level
121    ///
122    /// - **L1 (Bitcoin/Sui)**: This method is a local marker only.
123    ///   The actual single-use enforcement is done by the chain
124    ///   (UTXO spending / Object deletion).
125    ///
126    /// - **L2 (Aptos)**: This method is a local marker only.
127    ///   The Move VM enforces non-duplication of resources.
128    ///
129    /// - **L3 (Ethereum)**: The nullifier MUST be registered on-chain.
130    ///   The contract's `nullifiers[id] = true` is what enforces single-use.
131    ///
132    /// # Nullifier Construction
133    ///
134    /// `nullifier = tagged_hash("csv-nullifier", right_id || secret || context)`
135    ///
136    /// Where `context = H(chain_id || domain_separator)` binds the nullifier
137    /// to a specific chain context, preventing cross-chain replay attacks
138    /// even if the same secret is reused.
139    ///
140    /// # Arguments
141    /// * `secret` — The user's secret (prevents front-running)
142    /// * `chain_context` — Pre-computed context hash: `H(chain_id || domain_separator)`
143    ///
144    /// # Returns
145    /// The nullifier hash, or `None` for L1/L2 chains where the
146    /// nullifier is not needed (but returned for local tracking).
147    pub fn consume(
148        &mut self,
149        secret: Option<&[u8]>,
150        chain_context: Option<&[u8; 32]>,
151    ) -> Option<Hash> {
152        if let Some(secret) = secret {
153            // Build context binding: H(chain_id || domain_separator)
154            // If no chain_context provided, use empty context (backward compat)
155            let context_bytes = chain_context.unwrap_or(&[0u8; 32]);
156
157            // L3: Compute deterministic nullifier with domain-separated hashing
158            // nullifier = H("csv-nullifier" || right_id || secret || context)
159            let mut data = Vec::with_capacity(32 + self.id.0.as_bytes().len() + secret.len() + 32);
160            data.extend_from_slice(self.id.0.as_bytes());
161            data.extend_from_slice(secret);
162            data.extend_from_slice(context_bytes);
163            let nullifier = Hash::new(csv_tagged_hash("csv-nullifier", &data));
164            self.nullifier = Some(nullifier);
165            Some(nullifier)
166        } else {
167            // L1/L2: No nullifier needed — chain enforces structurally.
168            // Set a local consumption marker for tracking purposes.
169            None
170        }
171    }
172
173    /// Transfer this Right to a new owner.
174    ///
175    /// Creates a new Right with the same commitment and state but
176    /// different ownership. The original Right remains valid until
177    /// explicitly consumed.
178    ///
179    /// # Arguments
180    /// * `new_owner` - The new owner's ownership proof
181    /// * `transfer_salt` - A unique salt for the transfer to ensure unique ID
182    ///
183    /// # Returns
184    /// A new Right instance with the new owner and a fresh ID
185    pub fn transfer(&self, new_owner: OwnershipProof, transfer_salt: &[u8]) -> Right {
186        // Create a new Right with same commitment but new owner
187        let mut new_right = Right::new(self.commitment, new_owner, transfer_salt);
188
189        // Preserve state root if present
190        new_right.state_root = self.state_root;
191
192        // Preserve execution proof if present
193        new_right.execution_proof = self.execution_proof.clone();
194
195        new_right
196    }
197
198    /// Verify this Right's ownership and validity.
199    ///
200    /// This is the core client-side validation function. It checks:
201    /// 1. The ownership proof is cryptographically valid
202    /// 2. The Right ID is correctly derived from commitment || salt
203    /// 3. The commitment is well-formed
204    /// 4. The Right has not been consumed (nullifier not set)
205    ///
206    /// For full consignment validation, use the client-side
207    /// validation engine (Sprint 2).
208    pub fn verify(&self) -> Result<(), RightError> {
209        // Verify Right ID is correctly computed from commitment and salt
210        let expected_id = {
211            let mut data = Vec::with_capacity(32 + self.salt.len());
212            data.extend_from_slice(self.commitment.as_bytes());
213            data.extend_from_slice(&self.salt);
214            RightId(Hash::new(csv_tagged_hash("right-id", &data)))
215        };
216        if self.id != expected_id {
217            return Err(RightError::InvalidRightId);
218        }
219
220        // Cryptographically verify ownership proof
221        if let Some(scheme) = self.owner.scheme {
222            let signature = crate::signature::Signature::new(
223                self.owner.proof.clone(),
224                self.owner.owner.clone(),
225                self.commitment.as_bytes().to_vec(),
226            );
227            signature
228                .verify(scheme)
229                .map_err(|_| RightError::InvalidOwnershipProof)?;
230        } else {
231            // For L1 chains (Bitcoin/Sui) where ownership is structural,
232            // check that the proof is non-empty as a basic sanity check.
233            // Full UTXO/Object ownership is enforced by the chain itself.
234            if self.owner.proof.is_empty() {
235                return Err(RightError::MissingOwnershipProof);
236            }
237        }
238
239        // Check commitment is non-zero
240        if self.commitment.as_bytes() == &[0u8; 32] {
241            return Err(RightError::InvalidCommitment);
242        }
243
244        // Check Right has not been consumed
245        if self.nullifier.is_some() {
246            return Err(RightError::AlreadyConsumed);
247        }
248
249        Ok(())
250    }
251
252    /// Serialize this Right to canonical bytes.
253    ///
254    /// Used for hashing, signing, and transmission.
255    pub fn to_canonical_bytes(&self) -> Vec<u8> {
256        let mut out = Vec::new();
257        out.extend_from_slice(self.id.0.as_bytes());
258        out.extend_from_slice(self.commitment.as_bytes());
259        out.extend_from_slice(&(self.owner.proof.len() as u32).to_le_bytes());
260        out.extend_from_slice(&self.owner.proof);
261        out.extend_from_slice(&(self.owner.owner.len() as u32).to_le_bytes());
262        out.extend_from_slice(&self.owner.owner);
263        // Signature scheme (1 byte: 0=none, 1=secp256k1, 2=ed25519)
264        out.push(match self.owner.scheme {
265            None => 0,
266            Some(crate::signature::SignatureScheme::Secp256k1) => 1,
267            Some(crate::signature::SignatureScheme::Ed25519) => 2,
268        });
269        // Salt
270        out.extend_from_slice(&(self.salt.len() as u32).to_le_bytes());
271        out.extend_from_slice(&self.salt);
272        out.push(if self.nullifier.is_some() { 1 } else { 0 });
273        if let Some(nullifier) = &self.nullifier {
274            out.extend_from_slice(nullifier.as_bytes());
275        }
276        out.push(if self.state_root.is_some() { 1 } else { 0 });
277        if let Some(state_root) = &self.state_root {
278            out.extend_from_slice(state_root.as_bytes());
279        }
280        out.extend_from_slice(
281            &(self.execution_proof.as_ref().map_or(0, |p| p.len()) as u32).to_le_bytes(),
282        );
283        if let Some(proof) = &self.execution_proof {
284            out.extend_from_slice(proof);
285        }
286        out
287    }
288
289    /// Deserialize a Right from canonical bytes.
290    ///
291    /// # Errors
292    /// Returns `RightError::InvalidEncoding` if the bytes are malformed.
293    pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, RightError> {
294        let mut pos = 0;
295
296        // Read ID (32 bytes)
297        if bytes.len() < 32 {
298            return Err(RightError::InvalidEncoding);
299        }
300        let mut id_bytes = [0u8; 32];
301        id_bytes.copy_from_slice(&bytes[0..32]);
302        pos += 32;
303
304        // Read commitment (32 bytes)
305        if bytes.len() < pos + 32 {
306            return Err(RightError::InvalidEncoding);
307        }
308        let mut commitment_bytes = [0u8; 32];
309        commitment_bytes.copy_from_slice(&bytes[pos..pos + 32]);
310        pos += 32;
311
312        // Read owner proof length and data
313        if bytes.len() < pos + 4 {
314            return Err(RightError::InvalidEncoding);
315        }
316        let proof_len = u32::from_le_bytes(
317            bytes[pos..pos + 4]
318                .try_into()
319                .map_err(|_| RightError::InvalidEncoding)?,
320        ) as usize;
321        pos += 4;
322
323        if bytes.len() < pos + proof_len {
324            return Err(RightError::InvalidEncoding);
325        }
326        let proof = bytes[pos..pos + proof_len].to_vec();
327        pos += proof_len;
328
329        // Read owner identifier length and data
330        if bytes.len() < pos + 4 {
331            return Err(RightError::InvalidEncoding);
332        }
333        let owner_len = u32::from_le_bytes(
334            bytes[pos..pos + 4]
335                .try_into()
336                .map_err(|_| RightError::InvalidEncoding)?,
337        ) as usize;
338        pos += 4;
339
340        if bytes.len() < pos + owner_len {
341            return Err(RightError::InvalidEncoding);
342        }
343        let owner_data = bytes[pos..pos + owner_len].to_vec();
344        pos += owner_len;
345
346        // Read signature scheme (1 byte: 0=none, 1=secp256k1, 2=ed25519)
347        if pos >= bytes.len() {
348            return Err(RightError::InvalidEncoding);
349        }
350        let scheme = match bytes[pos] {
351            0 => None,
352            1 => Some(crate::signature::SignatureScheme::Secp256k1),
353            2 => Some(crate::signature::SignatureScheme::Ed25519),
354            _ => return Err(RightError::InvalidEncoding),
355        };
356        pos += 1;
357
358        // Read salt
359        if bytes.len() < pos + 4 {
360            return Err(RightError::InvalidEncoding);
361        }
362        let salt_len = u32::from_le_bytes(
363            bytes[pos..pos + 4]
364                .try_into()
365                .map_err(|_| RightError::InvalidEncoding)?,
366        ) as usize;
367        pos += 4;
368
369        if bytes.len() < pos + salt_len {
370            return Err(RightError::InvalidEncoding);
371        }
372        let salt = bytes[pos..pos + salt_len].to_vec();
373        pos += salt_len;
374
375        // Read nullifier flag and data
376        if pos >= bytes.len() {
377            return Err(RightError::InvalidEncoding);
378        }
379        let has_nullifier = bytes[pos] == 1;
380        pos += 1;
381
382        let nullifier = if has_nullifier {
383            if bytes.len() < pos + 32 {
384                return Err(RightError::InvalidEncoding);
385            }
386            let mut nullifier_bytes = [0u8; 32];
387            nullifier_bytes.copy_from_slice(&bytes[pos..pos + 32]);
388            pos += 32;
389            Some(Hash::new(nullifier_bytes))
390        } else {
391            None
392        };
393
394        // Read state_root flag and data
395        if pos >= bytes.len() {
396            return Err(RightError::InvalidEncoding);
397        }
398        let has_state_root = bytes[pos] == 1;
399        pos += 1;
400
401        let state_root = if has_state_root {
402            if bytes.len() < pos + 32 {
403                return Err(RightError::InvalidEncoding);
404            }
405            let mut state_root_bytes = [0u8; 32];
406            state_root_bytes.copy_from_slice(&bytes[pos..pos + 32]);
407            pos += 32;
408            Some(Hash::new(state_root_bytes))
409        } else {
410            None
411        };
412
413        // Read execution proof length and data
414        if bytes.len() < pos + 4 {
415            return Err(RightError::InvalidEncoding);
416        }
417        let proof_data_len = u32::from_le_bytes(
418            bytes[pos..pos + 4]
419                .try_into()
420                .map_err(|_| RightError::InvalidEncoding)?,
421        ) as usize;
422        pos += 4;
423
424        let execution_proof = if proof_data_len > 0 {
425            if bytes.len() < pos + proof_data_len {
426                return Err(RightError::InvalidEncoding);
427            }
428            Some(bytes[pos..pos + proof_data_len].to_vec())
429        } else {
430            None
431        };
432
433        // Reconstruct the Right
434        let id = RightId(Hash::new(id_bytes));
435        let commitment = Hash::new(commitment_bytes);
436        let owner = OwnershipProof {
437            proof,
438            owner: owner_data,
439            scheme,
440        };
441
442        // Verify RightId matches H(commitment || salt) before constructing
443        let expected_id = {
444            let mut data = Vec::with_capacity(32 + salt.len());
445            data.extend_from_slice(commitment.as_bytes());
446            data.extend_from_slice(&salt);
447            RightId(Hash::new(csv_tagged_hash("right-id", &data)))
448        };
449        if id != expected_id {
450            return Err(RightError::InvalidRightId);
451        }
452
453        let right = Self {
454            id,
455            commitment,
456            owner,
457            salt,
458            nullifier,
459            state_root,
460            execution_proof,
461        };
462
463        Ok(right)
464    }
465
466    /// Check if this Right has been consumed.
467    pub fn is_consumed(&self) -> bool {
468        self.nullifier.is_some()
469    }
470
471    /// Get the chain enforcement level indicator.
472    ///
473    /// Returns `true` if this is an L3 (cryptographic) Right that requires
474    /// nullifier tracking.
475    pub fn requires_nullifier(&self) -> bool {
476        self.nullifier.is_some()
477    }
478}
479
480/// Right validation errors.
481#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
482pub enum RightError {
483    /// The ownership proof is missing from the Right
484    #[error("Missing ownership proof")]
485    MissingOwnershipProof,
486    /// The ownership proof failed cryptographic signature verification
487    #[error("Invalid ownership proof: signature verification failed")]
488    InvalidOwnershipProof,
489    /// The commitment is invalid (zero hash)
490    #[error("Invalid commitment (zero hash)")]
491    InvalidCommitment,
492    /// The Right has already been consumed and cannot be used again
493    #[error("Right has already been consumed")]
494    AlreadyConsumed,
495    /// The nullifier is invalid or does not match the expected format
496    #[error("Invalid nullifier")]
497    InvalidNullifier,
498    /// The canonical encoding of the Right is invalid
499    #[error("Invalid canonical encoding")]
500    InvalidEncoding,
501    /// The RightId does not match the computed H(commitment || salt)
502    #[error("Invalid RightId: does not match H(commitment || salt)")]
503    InvalidRightId,
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    fn test_right() -> Right {
511        Right::new(
512            Hash::new([0xAB; 32]),
513            OwnershipProof {
514                proof: vec![0x01, 0x02, 0x03],
515                owner: vec![0xFF; 32],
516                scheme: None,
517            },
518            &[0x42; 16],
519        )
520    }
521
522    #[test]
523    fn test_right_creation() {
524        let right = test_right();
525        assert_eq!(right.commitment.as_bytes(), &[0xAB; 32]);
526        assert!(right.nullifier.is_none());
527        assert!(right.state_root.is_none());
528        assert!(right.execution_proof.is_none());
529    }
530
531    #[test]
532    fn test_right_id_deterministic() {
533        let r1 = test_right();
534        let r2 = test_right();
535        assert_eq!(r1.id, r2.id);
536    }
537
538    #[test]
539    fn test_right_id_unique_per_salt() {
540        let r1 = Right::new(
541            Hash::new([0xAB; 32]),
542            OwnershipProof {
543                proof: vec![0x01],
544                owner: vec![0xFF; 32],
545                scheme: None,
546            },
547            &[0x42; 16],
548        );
549        let r2 = Right::new(
550            Hash::new([0xAB; 32]),
551            OwnershipProof {
552                proof: vec![0x01],
553                owner: vec![0xFF; 32],
554                scheme: None,
555            },
556            &[0x99; 16],
557        );
558        assert_ne!(r1.id, r2.id);
559    }
560
561    #[test]
562    fn test_right_verify_valid() {
563        let right = test_right();
564        assert!(right.verify().is_ok());
565    }
566
567    #[test]
568    fn test_right_verify_missing_proof() {
569        let mut right = test_right();
570        right.owner.proof = vec![];
571        assert_eq!(right.verify(), Err(RightError::MissingOwnershipProof));
572    }
573
574    #[test]
575    fn test_right_verify_zero_commitment() {
576        let mut right = test_right();
577        right.commitment = Hash::new([0u8; 32]);
578        // Recompute ID to match the new commitment (so we test commitment check, not ID check)
579        let mut data = Vec::with_capacity(32 + right.salt.len());
580        data.extend_from_slice(right.commitment.as_bytes());
581        data.extend_from_slice(&right.salt);
582        right.id = RightId(Hash::new(csv_tagged_hash("right-id", &data)));
583        assert_eq!(right.verify(), Err(RightError::InvalidCommitment));
584    }
585
586    #[test]
587    fn test_right_consume_with_nullifier() {
588        let mut right = test_right();
589        let chain_context = [0x01u8; 32]; // Ethereum context
590        let nullifier = right.consume(Some(b"secret"), Some(&chain_context));
591        assert!(nullifier.is_some());
592        assert!(right.nullifier.is_some());
593        assert_eq!(right.verify(), Err(RightError::AlreadyConsumed));
594    }
595
596    #[test]
597    fn test_right_consume_without_nullifier() {
598        let mut right = test_right();
599        let result = right.consume(None, None);
600        assert!(result.is_none());
601        assert!(right.nullifier.is_none());
602        // L1/L2: Right is still valid locally (chain enforces structural single-use)
603        assert!(right.verify().is_ok());
604    }
605
606    #[test]
607    fn test_right_canonical_roundtrip() {
608        let right = test_right();
609        let bytes = right.to_canonical_bytes();
610        let decoded = Right::from_canonical_bytes(&bytes).expect("Should decode");
611        assert_eq!(decoded.id, right.id);
612        assert_eq!(decoded.commitment, right.commitment);
613        assert_eq!(decoded.owner, right.owner);
614        assert_eq!(decoded.nullifier, right.nullifier);
615        assert_eq!(decoded.state_root, right.state_root);
616    }
617
618    #[test]
619    fn test_right_canonical_roundtrip_with_nullifier() {
620        let mut right = test_right();
621        let chain_context = [0x01u8; 32];
622        right.consume(Some(b"secret"), Some(&chain_context));
623        let bytes = right.to_canonical_bytes();
624        let decoded = Right::from_canonical_bytes(&bytes).expect("Should decode");
625        assert_eq!(decoded.nullifier, right.nullifier);
626        assert!(decoded.is_consumed());
627    }
628
629    #[test]
630    fn test_right_from_canonical_bytes_invalid() {
631        // Empty bytes should fail
632        assert!(Right::from_canonical_bytes(&[]).is_err());
633        // Truncated bytes should fail
634        assert!(Right::from_canonical_bytes(&[0u8; 16]).is_err());
635    }
636
637    #[test]
638    fn test_right_transfer() {
639        let right = test_right();
640        let new_owner = OwnershipProof {
641            proof: vec![0xAA, 0xBB, 0xCC],
642            owner: vec![0xDD; 32],
643            scheme: None,
644        };
645        let transferred = right.transfer(new_owner.clone(), b"transfer-salt");
646
647        // New Right should have:
648        // - Different ID (due to different salt)
649        assert_ne!(transferred.id, right.id);
650        // - Same commitment
651        assert_eq!(transferred.commitment, right.commitment);
652        // - New owner
653        assert_eq!(transferred.owner, new_owner);
654        // - Not consumed
655        assert!(!transferred.is_consumed());
656        // - Original Right unaffected
657        assert!(!right.is_consumed());
658    }
659
660    #[test]
661    fn test_right_transfer_preserves_state_root() {
662        let mut right = test_right();
663        right.state_root = Some(Hash::new([0xCD; 32]));
664
665        let new_owner = OwnershipProof {
666            proof: vec![0x01],
667            owner: vec![0xFF; 32],
668            scheme: None,
669        };
670        let transferred = right.transfer(new_owner, b"transfer");
671
672        assert_eq!(transferred.state_root, right.state_root);
673    }
674
675    #[test]
676    fn test_right_is_consumed() {
677        let mut right = test_right();
678        assert!(!right.is_consumed());
679
680        right.consume(Some(b"secret"), Some(&[0x01u8; 32]));
681        assert!(right.is_consumed());
682    }
683
684    #[test]
685    fn test_right_requires_nullifier() {
686        let right_l1 = test_right(); // L1/L2 doesn't need nullifier
687        assert!(!right_l1.requires_nullifier());
688
689        let mut right_l3 = test_right();
690        right_l3.consume(Some(b"secret"), Some(&[0x01u8; 32])); // L3 does
691        assert!(right_l3.requires_nullifier());
692    }
693
694    #[test]
695    fn test_nullifier_context_binding() {
696        // Same right + same secret + different contexts => different nullifiers
697        // This prevents cross-chain replay attacks even if secret is reused.
698        let right1 = Right::new(
699            Hash::new([0xAB; 32]),
700            OwnershipProof {
701                proof: vec![0x01, 0x02, 0x03],
702                owner: vec![0xFF; 32],
703                scheme: None,
704            },
705            &[0x42; 16],
706        );
707        let mut right2 = right1.clone();
708        let mut right3 = right1.clone();
709
710        let ethereum_context = [0x03u8; 32]; // Ethereum domain
711        let sui_context = [0x01u8; 32]; // Sui domain
712
713        let n1 = right3.consume(Some(b"same-secret"), Some(&ethereum_context));
714        let n2 = right2.consume(Some(b"same-secret"), Some(&sui_context));
715
716        // Nullifiers MUST differ across contexts
717        assert_ne!(n1, n2, "Nullifiers must be context-bound");
718
719        // Without context, produces different nullifier than with context
720        let mut right_no_context = right1.clone();
721        let n3 = right_no_context.consume(Some(b"same-secret"), None);
722        assert_ne!(n1, n3, "Context must affect nullifier computation");
723    }
724
725    #[test]
726    fn test_nullifier_determinism() {
727        // Same right + same secret + same context => same nullifier
728        let mut right1 = Right::new(
729            Hash::new([0xAB; 32]),
730            OwnershipProof {
731                proof: vec![0x01],
732                owner: vec![0xFF; 32],
733                scheme: None,
734            },
735            &[0x42; 16],
736        );
737        let mut right2 = right1.clone();
738        let context = [0x03u8; 32];
739
740        let n1 = right1.consume(Some(b"secret"), Some(&context));
741        let n2 = right2.consume(Some(b"secret"), Some(&context));
742
743        assert_eq!(n1, n2, "Nullifier must be deterministic");
744    }
745
746    #[test]
747    fn test_right_verify_ed25519_signature() {
748        use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
749        use rand::rngs::OsRng;
750
751        let signing_key = SigningKey::generate(&mut OsRng);
752        let verifying_key: VerifyingKey = signing_key.verifying_key();
753        let commitment = Hash::new([0xAB; 32]);
754
755        // Sign the commitment
756        let signature = signing_key.sign(commitment.as_bytes());
757
758        let right = Right::new(
759            commitment,
760            OwnershipProof {
761                proof: signature.to_bytes().to_vec(),
762                owner: verifying_key.to_bytes().to_vec(),
763                scheme: Some(crate::signature::SignatureScheme::Ed25519),
764            },
765            &[0x42; 16],
766        );
767
768        assert!(right.verify().is_ok());
769    }
770
771    #[test]
772    fn test_right_verify_ed25519_wrong_message_fails() {
773        use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
774        use rand::rngs::OsRng;
775
776        let signing_key = SigningKey::generate(&mut OsRng);
777        let verifying_key: VerifyingKey = signing_key.verifying_key();
778
779        // Sign a different message (not the commitment)
780        let wrong_message = [0xCD; 32];
781        let signature = signing_key.sign(&wrong_message);
782
783        let right = Right::new(
784            Hash::new([0xAB; 32]), // Different from what was signed
785            OwnershipProof {
786                proof: signature.to_bytes().to_vec(),
787                owner: verifying_key.to_bytes().to_vec(),
788                scheme: Some(crate::signature::SignatureScheme::Ed25519),
789            },
790            &[0x42; 16],
791        );
792
793        // Verification should fail because the signature doesn't match the commitment
794        assert_eq!(right.verify(), Err(RightError::InvalidOwnershipProof));
795    }
796
797    #[test]
798    fn test_right_verify_secp256k1_signature() {
799        use secp256k1::{Message, Secp256k1, SecretKey};
800
801        let secp = Secp256k1::new();
802        let secret_key = SecretKey::new(&mut secp256k1::rand::thread_rng());
803        let public_key = secp256k1::PublicKey::from_secret_key(&secp, &secret_key);
804        let commitment = Hash::new([0xAB; 32]);
805
806        // Sign the commitment
807        let msg = Message::from_digest_slice(commitment.as_bytes()).unwrap();
808        let signature = secp.sign_ecdsa(&msg, &secret_key);
809
810        let right = Right::new(
811            commitment,
812            OwnershipProof {
813                proof: signature.serialize_compact().to_vec(),
814                owner: public_key.serialize().to_vec(),
815                scheme: Some(crate::signature::SignatureScheme::Secp256k1),
816            },
817            &[0x42; 16],
818        );
819
820        assert!(right.verify().is_ok());
821    }
822
823    #[test]
824    fn test_right_verify_tampered_proof_fails() {
825        use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
826        use rand::rngs::OsRng;
827
828        let signing_key = SigningKey::generate(&mut OsRng);
829        let verifying_key: VerifyingKey = signing_key.verifying_key();
830        let commitment = Hash::new([0xAB; 32]);
831
832        let signature = signing_key.sign(commitment.as_bytes());
833        let mut tampered_sig = signature.to_bytes().to_vec();
834        tampered_sig[0] ^= 0xFF; // Tamper with signature
835
836        let right = Right::new(
837            commitment,
838            OwnershipProof {
839                proof: tampered_sig,
840                owner: verifying_key.to_bytes().to_vec(),
841                scheme: Some(crate::signature::SignatureScheme::Ed25519),
842            },
843            &[0x42; 16],
844        );
845
846        assert_eq!(right.verify(), Err(RightError::InvalidOwnershipProof));
847    }
848
849    #[test]
850    fn test_right_id_spoofing_fails() {
851        // Attempt to create a Right with a mismatched ID
852        let mut right = test_right();
853        // Tamper with the ID
854        right.id = RightId(Hash::new([0xFF; 32]));
855        assert_eq!(right.verify(), Err(RightError::InvalidRightId));
856    }
857
858    #[test]
859    fn test_from_canonical_bytes_rejects_spoofed_id() {
860        let right = test_right();
861        let mut bytes = right.to_canonical_bytes();
862
863        // Tamper with the RightId in the serialized bytes (first 32 bytes)
864        for byte in &mut bytes[0..32] {
865            *byte ^= 0xFF;
866        }
867
868        assert_eq!(
869            Right::from_canonical_bytes(&bytes),
870            Err(RightError::InvalidRightId)
871        );
872    }
873}