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 =
160                Vec::with_capacity(32 + self.id.0.as_bytes().len() + secret.len() + 32);
161            data.extend_from_slice(self.id.0.as_bytes());
162            data.extend_from_slice(secret);
163            data.extend_from_slice(context_bytes);
164            let nullifier = Hash::new(csv_tagged_hash("csv-nullifier", &data));
165            self.nullifier = Some(nullifier);
166            Some(nullifier)
167        } else {
168            // L1/L2: No nullifier needed — chain enforces structurally.
169            // Set a local consumption marker for tracking purposes.
170            None
171        }
172    }
173
174    /// Transfer this Right to a new owner.
175    ///
176    /// Creates a new Right with the same commitment and state but
177    /// different ownership. The original Right remains valid until
178    /// explicitly consumed.
179    ///
180    /// # Arguments
181    /// * `new_owner` - The new owner's ownership proof
182    /// * `transfer_salt` - A unique salt for the transfer to ensure unique ID
183    ///
184    /// # Returns
185    /// A new Right instance with the new owner and a fresh ID
186    pub fn transfer(&self, new_owner: OwnershipProof, transfer_salt: &[u8]) -> Right {
187        // Create a new Right with same commitment but new owner
188        let mut new_right = Right::new(self.commitment, new_owner, transfer_salt);
189
190        // Preserve state root if present
191        new_right.state_root = self.state_root;
192
193        // Preserve execution proof if present
194        new_right.execution_proof = self.execution_proof.clone();
195
196        new_right
197    }
198
199    /// Verify this Right's ownership and validity.
200    ///
201    /// This is the core client-side validation function. It checks:
202    /// 1. The ownership proof is cryptographically valid
203    /// 2. The Right ID is correctly derived from commitment || salt
204    /// 3. The commitment is well-formed
205    /// 4. The Right has not been consumed (nullifier not set)
206    ///
207    /// For full consignment validation, use the client-side
208    /// validation engine (Sprint 2).
209    pub fn verify(&self) -> Result<(), RightError> {
210        // Verify Right ID is correctly computed from commitment and salt
211        let expected_id = {
212            let mut data = Vec::with_capacity(32 + self.salt.len());
213            data.extend_from_slice(self.commitment.as_bytes());
214            data.extend_from_slice(&self.salt);
215            RightId(Hash::new(csv_tagged_hash("right-id", &data)))
216        };
217        if self.id != expected_id {
218            return Err(RightError::InvalidRightId);
219        }
220
221        // Cryptographically verify ownership proof
222        if let Some(scheme) = self.owner.scheme {
223            let signature = crate::signature::Signature::new(
224                self.owner.proof.clone(),
225                self.owner.owner.clone(),
226                self.commitment.as_bytes().to_vec(),
227            );
228            signature
229                .verify(scheme)
230                .map_err(|_| RightError::InvalidOwnershipProof)?;
231        } else {
232            // For L1 chains (Bitcoin/Sui) where ownership is structural,
233            // check that the proof is non-empty as a basic sanity check.
234            // Full UTXO/Object ownership is enforced by the chain itself.
235            if self.owner.proof.is_empty() {
236                return Err(RightError::MissingOwnershipProof);
237            }
238        }
239
240        // Check commitment is non-zero
241        if self.commitment.as_bytes() == &[0u8; 32] {
242            return Err(RightError::InvalidCommitment);
243        }
244
245        // Check Right has not been consumed
246        if self.nullifier.is_some() {
247            return Err(RightError::AlreadyConsumed);
248        }
249
250        Ok(())
251    }
252
253    /// Serialize this Right to canonical bytes.
254    ///
255    /// Used for hashing, signing, and transmission.
256    pub fn to_canonical_bytes(&self) -> Vec<u8> {
257        let mut out = Vec::new();
258        out.extend_from_slice(self.id.0.as_bytes());
259        out.extend_from_slice(self.commitment.as_bytes());
260        out.extend_from_slice(&(self.owner.proof.len() as u32).to_le_bytes());
261        out.extend_from_slice(&self.owner.proof);
262        out.extend_from_slice(&(self.owner.owner.len() as u32).to_le_bytes());
263        out.extend_from_slice(&self.owner.owner);
264        // Signature scheme (1 byte: 0=none, 1=secp256k1, 2=ed25519)
265        out.push(match self.owner.scheme {
266            None => 0,
267            Some(crate::signature::SignatureScheme::Secp256k1) => 1,
268            Some(crate::signature::SignatureScheme::Ed25519) => 2,
269        });
270        // Salt
271        out.extend_from_slice(&(self.salt.len() as u32).to_le_bytes());
272        out.extend_from_slice(&self.salt);
273        out.push(if self.nullifier.is_some() { 1 } else { 0 });
274        if let Some(nullifier) = &self.nullifier {
275            out.extend_from_slice(nullifier.as_bytes());
276        }
277        out.push(if self.state_root.is_some() { 1 } else { 0 });
278        if let Some(state_root) = &self.state_root {
279            out.extend_from_slice(state_root.as_bytes());
280        }
281        out.extend_from_slice(
282            &(self.execution_proof.as_ref().map_or(0, |p| p.len()) as u32).to_le_bytes(),
283        );
284        if let Some(proof) = &self.execution_proof {
285            out.extend_from_slice(proof);
286        }
287        out
288    }
289
290    /// Deserialize a Right from canonical bytes.
291    ///
292    /// # Errors
293    /// Returns `RightError::InvalidEncoding` if the bytes are malformed.
294    pub fn from_canonical_bytes(bytes: &[u8]) -> Result<Self, RightError> {
295        let mut pos = 0;
296
297        // Read ID (32 bytes)
298        if bytes.len() < 32 {
299            return Err(RightError::InvalidEncoding);
300        }
301        let mut id_bytes = [0u8; 32];
302        id_bytes.copy_from_slice(&bytes[0..32]);
303        pos += 32;
304
305        // Read commitment (32 bytes)
306        if bytes.len() < pos + 32 {
307            return Err(RightError::InvalidEncoding);
308        }
309        let mut commitment_bytes = [0u8; 32];
310        commitment_bytes.copy_from_slice(&bytes[pos..pos + 32]);
311        pos += 32;
312
313        // Read owner proof length and data
314        if bytes.len() < pos + 4 {
315            return Err(RightError::InvalidEncoding);
316        }
317        let proof_len = u32::from_le_bytes(
318            bytes[pos..pos + 4]
319                .try_into()
320                .map_err(|_| RightError::InvalidEncoding)?,
321        ) as usize;
322        pos += 4;
323
324        if bytes.len() < pos + proof_len {
325            return Err(RightError::InvalidEncoding);
326        }
327        let proof = bytes[pos..pos + proof_len].to_vec();
328        pos += proof_len;
329
330        // Read owner identifier length and data
331        if bytes.len() < pos + 4 {
332            return Err(RightError::InvalidEncoding);
333        }
334        let owner_len = u32::from_le_bytes(
335            bytes[pos..pos + 4]
336                .try_into()
337                .map_err(|_| RightError::InvalidEncoding)?,
338        ) as usize;
339        pos += 4;
340
341        if bytes.len() < pos + owner_len {
342            return Err(RightError::InvalidEncoding);
343        }
344        let owner_data = bytes[pos..pos + owner_len].to_vec();
345        pos += owner_len;
346
347        // Read signature scheme (1 byte: 0=none, 1=secp256k1, 2=ed25519)
348        if pos >= bytes.len() {
349            return Err(RightError::InvalidEncoding);
350        }
351        let scheme = match bytes[pos] {
352            0 => None,
353            1 => Some(crate::signature::SignatureScheme::Secp256k1),
354            2 => Some(crate::signature::SignatureScheme::Ed25519),
355            _ => return Err(RightError::InvalidEncoding),
356        };
357        pos += 1;
358
359        // Read salt
360        if bytes.len() < pos + 4 {
361            return Err(RightError::InvalidEncoding);
362        }
363        let salt_len = u32::from_le_bytes(
364            bytes[pos..pos + 4]
365                .try_into()
366                .map_err(|_| RightError::InvalidEncoding)?,
367        ) as usize;
368        pos += 4;
369
370        if bytes.len() < pos + salt_len {
371            return Err(RightError::InvalidEncoding);
372        }
373        let salt = bytes[pos..pos + salt_len].to_vec();
374        pos += salt_len;
375
376        // Read nullifier flag and data
377        if pos >= bytes.len() {
378            return Err(RightError::InvalidEncoding);
379        }
380        let has_nullifier = bytes[pos] == 1;
381        pos += 1;
382
383        let nullifier = if has_nullifier {
384            if bytes.len() < pos + 32 {
385                return Err(RightError::InvalidEncoding);
386            }
387            let mut nullifier_bytes = [0u8; 32];
388            nullifier_bytes.copy_from_slice(&bytes[pos..pos + 32]);
389            pos += 32;
390            Some(Hash::new(nullifier_bytes))
391        } else {
392            None
393        };
394
395        // Read state_root flag and data
396        if pos >= bytes.len() {
397            return Err(RightError::InvalidEncoding);
398        }
399        let has_state_root = bytes[pos] == 1;
400        pos += 1;
401
402        let state_root = if has_state_root {
403            if bytes.len() < pos + 32 {
404                return Err(RightError::InvalidEncoding);
405            }
406            let mut state_root_bytes = [0u8; 32];
407            state_root_bytes.copy_from_slice(&bytes[pos..pos + 32]);
408            pos += 32;
409            Some(Hash::new(state_root_bytes))
410        } else {
411            None
412        };
413
414        // Read execution proof length and data
415        if bytes.len() < pos + 4 {
416            return Err(RightError::InvalidEncoding);
417        }
418        let proof_data_len = u32::from_le_bytes(
419            bytes[pos..pos + 4]
420                .try_into()
421                .map_err(|_| RightError::InvalidEncoding)?,
422        ) as usize;
423        pos += 4;
424
425        let execution_proof = if proof_data_len > 0 {
426            if bytes.len() < pos + proof_data_len {
427                return Err(RightError::InvalidEncoding);
428            }
429            Some(bytes[pos..pos + proof_data_len].to_vec())
430        } else {
431            None
432        };
433
434        // Reconstruct the Right
435        let id = RightId(Hash::new(id_bytes));
436        let commitment = Hash::new(commitment_bytes);
437        let owner = OwnershipProof {
438            proof,
439            owner: owner_data,
440            scheme,
441        };
442
443        // Verify RightId matches H(commitment || salt) before constructing
444        let expected_id = {
445            let mut data = Vec::with_capacity(32 + salt.len());
446            data.extend_from_slice(commitment.as_bytes());
447            data.extend_from_slice(&salt);
448            RightId(Hash::new(csv_tagged_hash("right-id", &data)))
449        };
450        if id != expected_id {
451            return Err(RightError::InvalidRightId);
452        }
453
454        let right = Self {
455            id,
456            commitment,
457            owner,
458            salt,
459            nullifier,
460            state_root,
461            execution_proof,
462        };
463
464        Ok(right)
465    }
466
467    /// Check if this Right has been consumed.
468    pub fn is_consumed(&self) -> bool {
469        self.nullifier.is_some()
470    }
471
472    /// Get the chain enforcement level indicator.
473    ///
474    /// Returns `true` if this is an L3 (cryptographic) Right that requires
475    /// nullifier tracking.
476    pub fn requires_nullifier(&self) -> bool {
477        self.nullifier.is_some()
478    }
479}
480
481/// Right validation errors.
482#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
483pub enum RightError {
484    /// The ownership proof is missing from the Right
485    #[error("Missing ownership proof")]
486    MissingOwnershipProof,
487    /// The ownership proof failed cryptographic signature verification
488    #[error("Invalid ownership proof: signature verification failed")]
489    InvalidOwnershipProof,
490    /// The commitment is invalid (zero hash)
491    #[error("Invalid commitment (zero hash)")]
492    InvalidCommitment,
493    /// The Right has already been consumed and cannot be used again
494    #[error("Right has already been consumed")]
495    AlreadyConsumed,
496    /// The nullifier is invalid or does not match the expected format
497    #[error("Invalid nullifier")]
498    InvalidNullifier,
499    /// The canonical encoding of the Right is invalid
500    #[error("Invalid canonical encoding")]
501    InvalidEncoding,
502    /// The RightId does not match the computed H(commitment || salt)
503    #[error("Invalid RightId: does not match H(commitment || salt)")]
504    InvalidRightId,
505}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    fn test_right() -> Right {
512        Right::new(
513            Hash::new([0xAB; 32]),
514            OwnershipProof {
515                proof: vec![0x01, 0x02, 0x03],
516                owner: vec![0xFF; 32],
517                scheme: None,
518            },
519            &[0x42; 16],
520        )
521    }
522
523    #[test]
524    fn test_right_creation() {
525        let right = test_right();
526        assert_eq!(right.commitment.as_bytes(), &[0xAB; 32]);
527        assert!(right.nullifier.is_none());
528        assert!(right.state_root.is_none());
529        assert!(right.execution_proof.is_none());
530    }
531
532    #[test]
533    fn test_right_id_deterministic() {
534        let r1 = test_right();
535        let r2 = test_right();
536        assert_eq!(r1.id, r2.id);
537    }
538
539    #[test]
540    fn test_right_id_unique_per_salt() {
541        let r1 = Right::new(
542            Hash::new([0xAB; 32]),
543            OwnershipProof {
544                proof: vec![0x01],
545                owner: vec![0xFF; 32],
546                scheme: None,
547            },
548            &[0x42; 16],
549        );
550        let r2 = Right::new(
551            Hash::new([0xAB; 32]),
552            OwnershipProof {
553                proof: vec![0x01],
554                owner: vec![0xFF; 32],
555                scheme: None,
556            },
557            &[0x99; 16],
558        );
559        assert_ne!(r1.id, r2.id);
560    }
561
562    #[test]
563    fn test_right_verify_valid() {
564        let right = test_right();
565        assert!(right.verify().is_ok());
566    }
567
568    #[test]
569    fn test_right_verify_missing_proof() {
570        let mut right = test_right();
571        right.owner.proof = vec![];
572        assert_eq!(right.verify(), Err(RightError::MissingOwnershipProof));
573    }
574
575    #[test]
576    fn test_right_verify_zero_commitment() {
577        let mut right = test_right();
578        right.commitment = Hash::new([0u8; 32]);
579        // Recompute ID to match the new commitment (so we test commitment check, not ID check)
580        let mut data = Vec::with_capacity(32 + right.salt.len());
581        data.extend_from_slice(right.commitment.as_bytes());
582        data.extend_from_slice(&right.salt);
583        right.id = RightId(Hash::new(csv_tagged_hash("right-id", &data)));
584        assert_eq!(right.verify(), Err(RightError::InvalidCommitment));
585    }
586
587    #[test]
588    fn test_right_consume_with_nullifier() {
589        let mut right = test_right();
590        let chain_context = [0x01u8; 32]; // Ethereum context
591        let nullifier = right.consume(Some(b"secret"), Some(&chain_context));
592        assert!(nullifier.is_some());
593        assert!(right.nullifier.is_some());
594        assert_eq!(right.verify(), Err(RightError::AlreadyConsumed));
595    }
596
597    #[test]
598    fn test_right_consume_without_nullifier() {
599        let mut right = test_right();
600        let result = right.consume(None, None);
601        assert!(result.is_none());
602        assert!(right.nullifier.is_none());
603        // L1/L2: Right is still valid locally (chain enforces structural single-use)
604        assert!(right.verify().is_ok());
605    }
606
607    #[test]
608    fn test_right_canonical_roundtrip() {
609        let right = test_right();
610        let bytes = right.to_canonical_bytes();
611        let decoded = Right::from_canonical_bytes(&bytes).expect("Should decode");
612        assert_eq!(decoded.id, right.id);
613        assert_eq!(decoded.commitment, right.commitment);
614        assert_eq!(decoded.owner, right.owner);
615        assert_eq!(decoded.nullifier, right.nullifier);
616        assert_eq!(decoded.state_root, right.state_root);
617    }
618
619    #[test]
620    fn test_right_canonical_roundtrip_with_nullifier() {
621        let mut right = test_right();
622        let chain_context = [0x01u8; 32];
623        right.consume(Some(b"secret"), Some(&chain_context));
624        let bytes = right.to_canonical_bytes();
625        let decoded = Right::from_canonical_bytes(&bytes).expect("Should decode");
626        assert_eq!(decoded.nullifier, right.nullifier);
627        assert!(decoded.is_consumed());
628    }
629
630    #[test]
631    fn test_right_from_canonical_bytes_invalid() {
632        // Empty bytes should fail
633        assert!(Right::from_canonical_bytes(&[]).is_err());
634        // Truncated bytes should fail
635        assert!(Right::from_canonical_bytes(&[0u8; 16]).is_err());
636    }
637
638    #[test]
639    fn test_right_transfer() {
640        let right = test_right();
641        let new_owner = OwnershipProof {
642            proof: vec![0xAA, 0xBB, 0xCC],
643            owner: vec![0xDD; 32],
644            scheme: None,
645        };
646        let transferred = right.transfer(new_owner.clone(), b"transfer-salt");
647
648        // New Right should have:
649        // - Different ID (due to different salt)
650        assert_ne!(transferred.id, right.id);
651        // - Same commitment
652        assert_eq!(transferred.commitment, right.commitment);
653        // - New owner
654        assert_eq!(transferred.owner, new_owner);
655        // - Not consumed
656        assert!(!transferred.is_consumed());
657        // - Original Right unaffected
658        assert!(!right.is_consumed());
659    }
660
661    #[test]
662    fn test_right_transfer_preserves_state_root() {
663        let mut right = test_right();
664        right.state_root = Some(Hash::new([0xCD; 32]));
665
666        let new_owner = OwnershipProof {
667            proof: vec![0x01],
668            owner: vec![0xFF; 32],
669            scheme: None,
670        };
671        let transferred = right.transfer(new_owner, b"transfer");
672
673        assert_eq!(transferred.state_root, right.state_root);
674    }
675
676    #[test]
677    fn test_right_is_consumed() {
678        let mut right = test_right();
679        assert!(!right.is_consumed());
680
681        right.consume(Some(b"secret"), Some(&[0x01u8; 32]));
682        assert!(right.is_consumed());
683    }
684
685    #[test]
686    fn test_right_requires_nullifier() {
687        let right_l1 = test_right(); // L1/L2 doesn't need nullifier
688        assert!(!right_l1.requires_nullifier());
689
690        let mut right_l3 = test_right();
691        right_l3.consume(Some(b"secret"), Some(&[0x01u8; 32])); // L3 does
692        assert!(right_l3.requires_nullifier());
693    }
694
695    #[test]
696    fn test_nullifier_context_binding() {
697        // Same right + same secret + different contexts => different nullifiers
698        // This prevents cross-chain replay attacks even if secret is reused.
699        let right1 = Right::new(
700            Hash::new([0xAB; 32]),
701            OwnershipProof {
702                proof: vec![0x01, 0x02, 0x03],
703                owner: vec![0xFF; 32],
704                scheme: None,
705            },
706            &[0x42; 16],
707        );
708        let mut right2 = right1.clone();
709        let mut right3 = right1.clone();
710
711        let ethereum_context = [0x03u8; 32]; // Ethereum domain
712        let sui_context = [0x01u8; 32]; // Sui domain
713
714        let n1 = right3.consume(Some(b"same-secret"), Some(&ethereum_context));
715        let n2 = right2.consume(Some(b"same-secret"), Some(&sui_context));
716
717        // Nullifiers MUST differ across contexts
718        assert_ne!(n1, n2, "Nullifiers must be context-bound");
719
720        // Without context, produces different nullifier than with context
721        let mut right_no_context = right1.clone();
722        let n3 = right_no_context.consume(Some(b"same-secret"), None);
723        assert_ne!(n1, n3, "Context must affect nullifier computation");
724    }
725
726    #[test]
727    fn test_nullifier_determinism() {
728        // Same right + same secret + same context => same nullifier
729        let mut right1 = Right::new(
730            Hash::new([0xAB; 32]),
731            OwnershipProof {
732                proof: vec![0x01],
733                owner: vec![0xFF; 32],
734                scheme: None,
735            },
736            &[0x42; 16],
737        );
738        let mut right2 = right1.clone();
739        let context = [0x03u8; 32];
740
741        let n1 = right1.consume(Some(b"secret"), Some(&context));
742        let n2 = right2.consume(Some(b"secret"), Some(&context));
743
744        assert_eq!(n1, n2, "Nullifier must be deterministic");
745    }
746
747    #[test]
748    fn test_right_verify_ed25519_signature() {
749        use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
750        use rand::rngs::OsRng;
751
752        let signing_key = SigningKey::generate(&mut OsRng);
753        let verifying_key: VerifyingKey = signing_key.verifying_key();
754        let commitment = Hash::new([0xAB; 32]);
755
756        // Sign the commitment
757        let signature = signing_key.sign(commitment.as_bytes());
758
759        let right = Right::new(
760            commitment,
761            OwnershipProof {
762                proof: signature.to_bytes().to_vec(),
763                owner: verifying_key.to_bytes().to_vec(),
764                scheme: Some(crate::signature::SignatureScheme::Ed25519),
765            },
766            &[0x42; 16],
767        );
768
769        assert!(right.verify().is_ok());
770    }
771
772    #[test]
773    fn test_right_verify_ed25519_wrong_message_fails() {
774        use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
775        use rand::rngs::OsRng;
776
777        let signing_key = SigningKey::generate(&mut OsRng);
778        let verifying_key: VerifyingKey = signing_key.verifying_key();
779
780        // Sign a different message (not the commitment)
781        let wrong_message = [0xCD; 32];
782        let signature = signing_key.sign(&wrong_message);
783
784        let right = Right::new(
785            Hash::new([0xAB; 32]), // Different from what was signed
786            OwnershipProof {
787                proof: signature.to_bytes().to_vec(),
788                owner: verifying_key.to_bytes().to_vec(),
789                scheme: Some(crate::signature::SignatureScheme::Ed25519),
790            },
791            &[0x42; 16],
792        );
793
794        // Verification should fail because the signature doesn't match the commitment
795        assert_eq!(right.verify(), Err(RightError::InvalidOwnershipProof));
796    }
797
798    #[test]
799    fn test_right_verify_secp256k1_signature() {
800        use secp256k1::{Message, Secp256k1, SecretKey};
801
802        let secp = Secp256k1::new();
803        let secret_key = SecretKey::new(&mut secp256k1::rand::thread_rng());
804        let public_key = secp256k1::PublicKey::from_secret_key(&secp, &secret_key);
805        let commitment = Hash::new([0xAB; 32]);
806
807        // Sign the commitment
808        let msg = Message::from_digest_slice(commitment.as_bytes()).unwrap();
809        let signature = secp.sign_ecdsa(&msg, &secret_key);
810
811        let right = Right::new(
812            commitment,
813            OwnershipProof {
814                proof: signature.serialize_compact().to_vec(),
815                owner: public_key.serialize().to_vec(),
816                scheme: Some(crate::signature::SignatureScheme::Secp256k1),
817            },
818            &[0x42; 16],
819        );
820
821        assert!(right.verify().is_ok());
822    }
823
824    #[test]
825    fn test_right_verify_tampered_proof_fails() {
826        use ed25519_dalek::{Signer, SigningKey, VerifyingKey};
827        use rand::rngs::OsRng;
828
829        let signing_key = SigningKey::generate(&mut OsRng);
830        let verifying_key: VerifyingKey = signing_key.verifying_key();
831        let commitment = Hash::new([0xAB; 32]);
832
833        let signature = signing_key.sign(commitment.as_bytes());
834        let mut tampered_sig = signature.to_bytes().to_vec();
835        tampered_sig[0] ^= 0xFF; // Tamper with signature
836
837        let right = Right::new(
838            commitment,
839            OwnershipProof {
840                proof: tampered_sig,
841                owner: verifying_key.to_bytes().to_vec(),
842                scheme: Some(crate::signature::SignatureScheme::Ed25519),
843            },
844            &[0x42; 16],
845        );
846
847        assert_eq!(right.verify(), Err(RightError::InvalidOwnershipProof));
848    }
849
850    #[test]
851    fn test_right_id_spoofing_fails() {
852        // Attempt to create a Right with a mismatched ID
853        let mut right = test_right();
854        // Tamper with the ID
855        right.id = RightId(Hash::new([0xFF; 32]));
856        assert_eq!(right.verify(), Err(RightError::InvalidRightId));
857    }
858
859    #[test]
860    fn test_from_canonical_bytes_rejects_spoofed_id() {
861        let right = test_right();
862        let mut bytes = right.to_canonical_bytes();
863
864        // Tamper with the RightId in the serialized bytes (first 32 bytes)
865        for byte in &mut bytes[0..32] {
866            *byte ^= 0xFF;
867        }
868
869        assert_eq!(
870            Right::from_canonical_bytes(&bytes),
871            Err(RightError::InvalidRightId)
872        );
873    }
874}