Skip to main content

csv_adapter_core/
seal_registry.rs

1//! Cross-Chain Seal Registry
2//!
3//! Tracks consumed seals across all chains to detect double-consumption
4//! attempts, including cross-chain double-spends.
5//!
6//! ## Purpose
7//!
8//! When a Right is consumed, the seal that enforced it is recorded here.
9//! This allows the client to detect if the same seal (or equivalent seal
10//! on another chain) has been used more than once.
11//!
12//! ## Chain-Specific Seal Types
13//!
14//! | Chain | Seal Type | Identifier |
15//! |-------|-----------|------------|
16//! | Bitcoin | UTXO spend | txid:vout |
17//! | Sui | Object deletion | object_id:version |
18//! | Aptos | Resource destruction | resource_address:account |
19//! | Ethereum | Nullifier registration | nullifier_hash |
20//!
21//! ## Cross-Chain Detection
22//!
23//! The registry maps all seal types to a unified `SealIdentity` that can
24//! be compared across chains. This detects:
25//! - Same seal used twice on the same chain
26//! - Equivalent seals used on different chains (cross-chain double-spend)
27
28use alloc::collections::{BTreeMap, BTreeSet};
29use alloc::string::String;
30use alloc::vec::Vec;
31
32use crate::hash::Hash;
33use crate::right::RightId;
34use crate::seal::SealRef;
35
36/// The chain that enforces this seal's single-use.
37#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
38#[allow(missing_docs)]
39pub enum ChainId {
40    /// Bitcoin chain (UTXO seals)
41    Bitcoin,
42    /// Sui blockchain (Object seals)
43    Sui,
44    /// Aptos blockchain (Resource seals)
45    Aptos,
46    /// Ethereum blockchain (Nullifier seals)
47    Ethereum,
48    /// Custom or unknown chain
49    Custom(String),
50}
51
52/// A seal consumption event recording when and where a seal was used.
53#[derive(Clone, Debug, Serialize, Deserialize)]
54pub struct SealConsumption {
55    /// Which chain enforced this consumption
56    pub chain: ChainId,
57    /// The seal reference (chain-specific format)
58    pub seal_ref: SealRef,
59    /// The Right that was consumed
60    pub right_id: RightId,
61    /// Block height when this was consumed
62    pub block_height: u64,
63    /// Transaction/operation hash that consumed this seal
64    pub tx_hash: Hash,
65    /// Timestamp (Unix epoch seconds) when this was recorded
66    pub recorded_at: u64,
67}
68
69/// Result of checking if a seal has been consumed.
70#[derive(Debug, Clone)]
71#[allow(missing_docs)]
72pub enum SealStatus {
73    /// Seal has not been consumed
74    Unconsumed,
75    /// Seal was consumed on a specific chain
76    ConsumedOnChain {
77        chain: ChainId,
78        consumption: SealConsumption,
79    },
80    /// Seal was consumed on multiple chains (double-spend detected)
81    DoubleSpent { consumptions: Vec<SealConsumption> },
82}
83
84/// Cross-chain seal registry.
85///
86/// Tracks all consumed seals across all chains and provides
87/// double-consumption detection.
88#[derive(Default)]
89pub struct CrossChainSealRegistry {
90    /// Map from seal identity to consumption events
91    consumed_seals: BTreeMap<Vec<u8>, Vec<SealConsumption>>,
92    /// Map from Right ID to seals that consumed it
93    right_consumption_map: BTreeMap<Hash, Vec<SealConsumption>>,
94    /// Set of known chain identifiers
95    known_chains: BTreeSet<ChainId>,
96}
97
98impl CrossChainSealRegistry {
99    /// Create a new empty registry.
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Record a seal consumption event.
105    ///
106    /// # Returns
107    /// - `Ok(())` if this is the first consumption of this seal
108    /// - `Err(double_spend)` if the seal was already consumed
109    ///   (but the consumption is still recorded for auditing)
110    pub fn record_consumption(
111        &mut self,
112        consumption: SealConsumption,
113    ) -> Result<(), Box<DoubleSpendError>> {
114        let seal_key = consumption.seal_ref.to_vec();
115        let is_double_spend = self.consumed_seals.contains_key(&seal_key)
116            && !self
117                .consumed_seals
118                .get(&seal_key)
119                .map_or(true, |v| v.is_empty());
120
121        // Track known chains
122        self.known_chains.insert(consumption.chain.clone());
123
124        // Check if already consumed
125        if is_double_spend {
126            let existing = self.consumed_seals.get(&seal_key).unwrap();
127            let is_cross_chain = existing.iter().any(|e| e.chain != consumption.chain);
128
129            let err = DoubleSpendError {
130                seal_ref: consumption.seal_ref.clone(),
131                existing_consumptions: existing.clone(),
132                new_consumption: consumption.clone(),
133                is_cross_chain,
134            };
135
136            // Still record for auditing purposes
137            self.consumed_seals
138                .entry(seal_key)
139                .or_default()
140                .push(consumption.clone());
141
142            self.right_consumption_map
143                .entry(consumption.right_id.0)
144                .or_default()
145                .push(consumption);
146
147            return Err(Box::new(err));
148        }
149
150        // Record the consumption
151        self.consumed_seals
152            .entry(seal_key)
153            .or_default()
154            .push(consumption.clone());
155
156        // Track by Right ID
157        self.right_consumption_map
158            .entry(consumption.right_id.0)
159            .or_default()
160            .push(consumption);
161
162        Ok(())
163    }
164
165    /// Check the status of a seal.
166    pub fn check_seal_status(&self, seal_ref: &SealRef) -> SealStatus {
167        let key = seal_ref.to_vec();
168
169        match self.consumed_seals.get(&key) {
170            None => SealStatus::Unconsumed,
171            Some(consumptions) if consumptions.len() == 1 => {
172                let c = &consumptions[0];
173                SealStatus::ConsumedOnChain {
174                    chain: c.chain.clone(),
175                    consumption: c.clone(),
176                }
177            }
178            Some(consumptions) => SealStatus::DoubleSpent {
179                consumptions: consumptions.clone(),
180            },
181        }
182    }
183
184    /// Check if a seal has been consumed (anywhere).
185    pub fn is_seal_consumed(&self, seal_ref: &SealRef) -> bool {
186        self.consumed_seals.contains_key(&seal_ref.to_vec())
187    }
188
189    /// Get all consumption events for a specific seal.
190    pub fn get_consumption_history(&self, seal_ref: &SealRef) -> Vec<SealConsumption> {
191        self.consumed_seals
192            .get(&seal_ref.to_vec())
193            .cloned()
194            .unwrap_or_default()
195    }
196
197    /// Get all seals consumed by a specific Right.
198    pub fn get_seals_for_right(&self, right_id: &RightId) -> Vec<SealConsumption> {
199        self.right_consumption_map
200            .get(&right_id.0)
201            .cloned()
202            .unwrap_or_default()
203    }
204
205    /// Get all known chains.
206    pub fn known_chains(&self) -> Vec<&ChainId> {
207        self.known_chains.iter().collect()
208    }
209
210    /// Get total number of unique seals tracked.
211    pub fn total_seals(&self) -> usize {
212        self.consumed_seals.len()
213    }
214
215    /// Get number of double-spend incidents detected.
216    pub fn double_spend_count(&self) -> usize {
217        self.consumed_seals
218            .values()
219            .filter(|consumptions| consumptions.len() > 1)
220            .count()
221    }
222}
223
224/// Error returned when a double-spend is detected.
225#[derive(Debug, Clone)]
226#[allow(missing_docs)]
227pub struct DoubleSpendError {
228    /// The seal that was double-spent
229    pub seal_ref: SealRef,
230    /// Existing consumption events
231    pub existing_consumptions: Vec<SealConsumption>,
232    /// The new consumption attempt
233    pub new_consumption: SealConsumption,
234    /// Whether this is a cross-chain double-spend
235    pub is_cross_chain: bool,
236}
237
238impl core::fmt::Display for DoubleSpendError {
239    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
240        if self.is_cross_chain {
241            write!(
242                f,
243                "Cross-chain double-spend detected for seal {:?}",
244                self.seal_ref
245            )
246        } else {
247            write!(f, "Same-chain replay detected for seal {:?}", self.seal_ref)
248        }
249    }
250}
251
252use serde::{Deserialize, Serialize};
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    fn make_consumption(chain: ChainId, seal_bytes: Vec<u8>, right_id: RightId) -> SealConsumption {
259        SealConsumption {
260            chain,
261            seal_ref: SealRef::new(seal_bytes, None).unwrap(),
262            right_id,
263            block_height: 100,
264            tx_hash: Hash::new([0xAB; 32]),
265            recorded_at: 1_000_000,
266        }
267    }
268
269    #[test]
270    fn test_record_single_consumption() {
271        let mut registry = CrossChainSealRegistry::new();
272        let right_id = RightId(Hash::new([0xCD; 32]));
273        let consumption = make_consumption(ChainId::Bitcoin, vec![0x01], right_id);
274
275        assert!(registry.record_consumption(consumption).is_ok());
276        assert_eq!(registry.total_seals(), 1);
277        assert_eq!(registry.double_spend_count(), 0);
278    }
279
280    #[test]
281    fn test_detect_same_chain_replay() {
282        let mut registry = CrossChainSealRegistry::new();
283        let right_id = RightId(Hash::new([0xCD; 32]));
284        let seal_bytes = vec![0x01];
285
286        let consumption1 = make_consumption(ChainId::Bitcoin, seal_bytes.clone(), right_id);
287        registry.record_consumption(consumption1).unwrap();
288
289        // Try to consume the same seal again on Bitcoin
290        let right_id2 = RightId(Hash::new([0xEF; 32]));
291        let consumption2 = make_consumption(ChainId::Bitcoin, seal_bytes, right_id2);
292        let result = registry.record_consumption(consumption2);
293
294        assert!(result.is_err());
295        let err = result.unwrap_err();
296        assert!(!err.is_cross_chain);
297    }
298
299    #[test]
300    fn test_detect_cross_chain_double_spend() {
301        let mut registry = CrossChainSealRegistry::new();
302        let right_id = RightId(Hash::new([0xCD; 32]));
303        let seal_bytes = vec![0x01];
304
305        // Consume on Bitcoin
306        let consumption1 = make_consumption(ChainId::Bitcoin, seal_bytes.clone(), right_id.clone());
307        registry.record_consumption(consumption1).unwrap();
308
309        // Try to consume on Ethereum (cross-chain double-spend)
310        let consumption2 = make_consumption(ChainId::Ethereum, seal_bytes, right_id);
311        let result = registry.record_consumption(consumption2);
312
313        assert!(result.is_err());
314        let err = result.unwrap_err();
315        assert!(err.is_cross_chain);
316        assert_eq!(err.existing_consumptions.len(), 1);
317    }
318
319    #[test]
320    fn test_seal_status_unconsumed() {
321        let registry = CrossChainSealRegistry::new();
322        let seal = SealRef::new(vec![0x01], None).unwrap();
323
324        assert!(matches!(
325            registry.check_seal_status(&seal),
326            SealStatus::Unconsumed
327        ));
328    }
329
330    #[test]
331    fn test_seal_status_consumed() {
332        let mut registry = CrossChainSealRegistry::new();
333        let right_id = RightId(Hash::new([0xCD; 32]));
334        let seal = SealRef::new(vec![0x01], None).unwrap();
335
336        let consumption = make_consumption(ChainId::Bitcoin, vec![0x01], right_id);
337        registry.record_consumption(consumption).unwrap();
338
339        match registry.check_seal_status(&seal) {
340            SealStatus::ConsumedOnChain { chain, .. } => {
341                assert_eq!(chain, ChainId::Bitcoin);
342            }
343            _ => panic!("Expected ConsumedOnChain"),
344        }
345    }
346
347    #[test]
348    fn test_seal_status_double_spent() {
349        let mut registry = CrossChainSealRegistry::new();
350        let right_id = RightId(Hash::new([0xCD; 32]));
351        let seal = SealRef::new(vec![0x01], None).unwrap();
352        let seal_bytes = vec![0x01];
353
354        // Consume on Bitcoin
355        let c1 = make_consumption(ChainId::Bitcoin, seal_bytes.clone(), right_id.clone());
356        registry.record_consumption(c1).unwrap();
357
358        // Try to consume on Ethereum (will be recorded in history but flagged as double-spend)
359        let c2 = make_consumption(ChainId::Ethereum, seal_bytes, right_id.clone());
360
361        // Note: record_consumption returns error, but we can still check status
362        let _ = registry.record_consumption(c2);
363
364        assert!(matches!(
365            registry.check_seal_status(&seal),
366            SealStatus::DoubleSpent { .. }
367        ));
368    }
369
370    #[test]
371    fn test_known_chains() {
372        let mut registry = CrossChainSealRegistry::new();
373        assert_eq!(registry.known_chains().len(), 0);
374
375        let right_id = RightId(Hash::new([0xCD; 32]));
376        let c1 = make_consumption(ChainId::Bitcoin, vec![0x01], right_id.clone());
377        registry.record_consumption(c1).unwrap();
378
379        assert_eq!(registry.known_chains().len(), 1);
380    }
381}