1use 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#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
38#[allow(missing_docs)]
39pub enum ChainId {
40 Bitcoin,
42 Sui,
44 Aptos,
46 Ethereum,
48 Custom(String),
50}
51
52#[derive(Clone, Debug, Serialize, Deserialize)]
54pub struct SealConsumption {
55 pub chain: ChainId,
57 pub seal_ref: SealRef,
59 pub right_id: RightId,
61 pub block_height: u64,
63 pub tx_hash: Hash,
65 pub recorded_at: u64,
67}
68
69#[derive(Debug, Clone)]
71#[allow(missing_docs)]
72pub enum SealStatus {
73 Unconsumed,
75 ConsumedOnChain {
77 chain: ChainId,
78 consumption: SealConsumption,
79 },
80 DoubleSpent { consumptions: Vec<SealConsumption> },
82}
83
84#[derive(Default)]
89pub struct CrossChainSealRegistry {
90 consumed_seals: BTreeMap<Vec<u8>, Vec<SealConsumption>>,
92 right_consumption_map: BTreeMap<Hash, Vec<SealConsumption>>,
94 known_chains: BTreeSet<ChainId>,
96}
97
98impl CrossChainSealRegistry {
99 pub fn new() -> Self {
101 Self::default()
102 }
103
104 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 self.known_chains.insert(consumption.chain.clone());
123
124 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 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 self.consumed_seals
152 .entry(seal_key)
153 .or_default()
154 .push(consumption.clone());
155
156 self.right_consumption_map
158 .entry(consumption.right_id.0)
159 .or_default()
160 .push(consumption);
161
162 Ok(())
163 }
164
165 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 pub fn is_seal_consumed(&self, seal_ref: &SealRef) -> bool {
186 self.consumed_seals.contains_key(&seal_ref.to_vec())
187 }
188
189 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 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 pub fn known_chains(&self) -> Vec<&ChainId> {
207 self.known_chains.iter().collect()
208 }
209
210 pub fn total_seals(&self) -> usize {
212 self.consumed_seals.len()
213 }
214
215 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#[derive(Debug, Clone)]
226#[allow(missing_docs)]
227pub struct DoubleSpendError {
228 pub seal_ref: SealRef,
230 pub existing_consumptions: Vec<SealConsumption>,
232 pub new_consumption: SealConsumption,
234 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 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 let consumption1 = make_consumption(ChainId::Bitcoin, seal_bytes.clone(), right_id.clone());
307 registry.record_consumption(consumption1).unwrap();
308
309 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 let c1 = make_consumption(ChainId::Bitcoin, seal_bytes.clone(), right_id.clone());
356 registry.record_consumption(c1).unwrap();
357
358 let c2 = make_consumption(ChainId::Ethereum, seal_bytes, right_id.clone());
360
361 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}