Skip to main content

coven_protocol/circle_control/
epoch_close.rs

1use super::*;
2
3#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
4#[serde(deny_unknown_fields)]
5pub struct ActiveCircleEpochCore {
6    pub epoch_id: CircleEpochId,
7    pub key_fingerprint: KeyFingerprint,
8    pub owners: Vec<String>,
9    pub access_root: ObjectHash,
10    pub origin: CircleEpochOrigin,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case", deny_unknown_fields)]
15pub enum CircleEpochOrigin {
16    Founder,
17    Closed {
18        closed_epoch_id: CircleEpochId,
19        close_control: CircleControlCoord,
20        close_id: CircleEpochCloseId,
21        outcome_hash: ObjectHash,
22        cutoff: CommitFrontier,
23    },
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct MergeActiveCircleEpoch {
29    pub common: ActiveCircleEpochCore,
30    pub metadata: MergeCircleMetadataStateRef,
31    pub roster: MergeCircleRosterStateRef,
32    pub store_membership: StoreMembershipStateRef,
33    pub covered_control_heads: Vec<MergeCircleControlHeadRef>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct CircleEpochCloseParticipant {
39    pub registration: StoreDeviceRegistrationRef,
40    pub response_slot: ObjectSlot,
41}
42
43/// The wire body of an Owner's intent to close an epoch. Every field here is
44/// signed.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct CircleEpochCloseIntentBody {
48    pub store_root_hash: ObjectHash,
49    pub circle_id: CircleId,
50    pub close_id: CircleEpochCloseId,
51    pub epoch_id: CircleEpochId,
52    pub predecessor_roster: MergeCircleRosterStateRef,
53    pub removal: CircleRosterEntry,
54    pub remaining_roster_state_hash: ObjectHash,
55    pub owner_pubkey: String,
56}
57
58impl SignedBody for CircleEpochCloseIntentBody {
59    const DOMAIN: &'static [u8] = CLOSE_INTENT_DOMAIN;
60}
61
62pub type CircleEpochCloseIntent = Signed<CircleEpochCloseIntentBody>;
63
64impl CircleEpochCloseIntentBody {
65    pub(super) fn verify_shape(&self) -> bool {
66        self.removal.verify()
67            && self.removal.store_root_hash == self.store_root_hash
68            && self.removal.circle_id == self.circle_id
69            && self.removal.author_pubkey == self.owner_pubkey
70            && matches!(
71                self.removal.change,
72                crate::circle_roster::CircleRosterChange::RemoveMember { .. }
73            )
74    }
75}
76
77impl CircleEpochCloseIntent {
78    #[allow(clippy::too_many_arguments)]
79    pub fn signed(
80        store_root_hash: ObjectHash,
81        circle_id: CircleId,
82        close_id: CircleEpochCloseId,
83        epoch_id: CircleEpochId,
84        predecessor_roster: MergeCircleRosterStateRef,
85        removal: CircleRosterEntry,
86        remaining_roster_state_hash: ObjectHash,
87        signer: &dyn coven_keys::keys::IdentityKeyAuthority,
88    ) -> Result<Self, CircleTransitionError> {
89        let body = CircleEpochCloseIntentBody {
90            store_root_hash,
91            circle_id,
92            close_id,
93            epoch_id,
94            predecessor_roster,
95            removal,
96            remaining_roster_state_hash,
97            owner_pubkey: keys::public_key_hex(signer),
98        };
99        if !body.verify_shape() {
100            return Err(CircleTransitionError::InvalidCurrentState);
101        }
102        Ok(Signed::sign(body, signer))
103    }
104
105    pub fn verify(&self) -> bool {
106        self.body().verify_shape() && self.verify_by(&self.owner_pubkey).is_ok()
107    }
108
109    pub fn intent_hash(&self) -> ObjectHash {
110        self.hash()
111    }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
115#[serde(deny_unknown_fields)]
116pub struct CircleEpochCloseIntentRef {
117    pub close_id: CircleEpochCloseId,
118    pub intent_hash: ObjectHash,
119    pub object: ExactObjectRef,
120}
121
122impl CircleEpochCloseIntentRef {
123    pub fn from_intent(
124        intent: &CircleEpochCloseIntent,
125        object: ExactObjectRef,
126    ) -> Result<Self, CircleTransitionError> {
127        let reference = Self {
128            close_id: intent.close_id,
129            intent_hash: intent.intent_hash(),
130            object,
131        };
132        if reference.object.slot().logical_key()
133            != format!(
134                "{}.json",
135                circle_epoch_close_intent_semantic_prefix(
136                    intent.circle_id,
137                    intent.close_id,
138                    intent.intent_hash(),
139                )
140            )
141        {
142            return Err(CircleTransitionError::InvalidCurrentState);
143        }
144        Ok(reference)
145    }
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(deny_unknown_fields)]
150pub struct CircleEpochClose {
151    pub close_id: CircleEpochCloseId,
152    pub frozen_epoch: MergeActiveCircleEpoch,
153    pub intent: CircleEpochCloseIntentRef,
154    pub frozen_device_state: StoreDeviceStateRef,
155    pub participants: Vec<CircleEpochCloseParticipant>,
156    pub provisional_frontier: CommitFrontier,
157    pub outcome_slot: ObjectSlot,
158}
159
160impl CircleEpochClose {
161    pub(super) fn verify_shape(&self, circle_id: CircleId) -> bool {
162        crate::store_commit::validate_commit_frontier(&self.provisional_frontier).is_ok()
163            && self.intent.close_id == self.close_id
164            && !self.participants.is_empty()
165            && self
166                .participants
167                .windows(2)
168                .all(|pair| pair[0].registration.device_id < pair[1].registration.device_id)
169            && self.participants.iter().all(|participant| {
170                participant.response_slot.logical_key()
171                    == format!(
172                        "{}.json",
173                        circle_epoch_close_response_semantic_prefix(
174                            circle_id,
175                            self.close_id,
176                            participant.registration.device_id,
177                        )
178                    )
179            })
180            && self.outcome_slot.logical_key()
181                == format!(
182                    "{}.json",
183                    circle_epoch_close_outcome_semantic_prefix(circle_id, self.close_id)
184                )
185    }
186}
187
188/// The wire body of one participant device's close response. Every field here
189/// is signed.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(deny_unknown_fields)]
192pub struct CircleEpochCloseResponseBody {
193    pub store_root_hash: ObjectHash,
194    pub circle_id: CircleId,
195    pub close_id: CircleEpochCloseId,
196    pub close_control: CircleControlCoord,
197    pub registration: StoreDeviceRegistrationRef,
198    pub frontier: CommitFrontier,
199}
200
201impl SignedBody for CircleEpochCloseResponseBody {
202    const DOMAIN: &'static [u8] = CLOSE_RESPONSE_DOMAIN;
203}
204
205pub type CircleEpochCloseResponse = Signed<CircleEpochCloseResponseBody>;
206
207impl CircleEpochCloseResponse {
208    pub fn signed(
209        control: &PreparedCircleControl,
210        registration: StoreDeviceRegistrationRef,
211        frontier: CommitFrontier,
212        author: &StoreDeviceRegistration,
213        signer: &UserKeypair,
214    ) -> Result<Self, CircleTransitionError> {
215        let CircleControlState::EpochClose(close) = control.value.state() else {
216            return Err(CircleTransitionError::InvalidCurrentState);
217        };
218        let response = Signed::sign(
219            CircleEpochCloseResponseBody {
220                store_root_hash: control.value.store_root_hash,
221                circle_id: control.value.circle_id,
222                close_id: close.close_id,
223                close_control: control.coord.clone(),
224                registration,
225                frontier,
226            },
227            signer,
228        );
229        if !response.verify_for(control, author) {
230            return Err(CircleTransitionError::InvalidCurrentState);
231        }
232        Ok(response)
233    }
234
235    pub fn verify_for(
236        &self,
237        control: &PreparedCircleControl,
238        author: &StoreDeviceRegistration,
239    ) -> bool {
240        let CircleControlState::EpochClose(close) = control.value.state() else {
241            return false;
242        };
243        control.verify()
244            && self.store_root_hash == control.value.store_root_hash
245            && self.circle_id == control.value.circle_id
246            && self.close_id == close.close_id
247            && self.close_control == control.coord
248            && crate::store_commit::validate_commit_frontier(&self.frontier).is_ok()
249            && self.frontier.covers(&close.provisional_frontier)
250            && self.registration.verify_registration(author).is_ok()
251            && author.store_root.store_root_hash == self.store_root_hash
252            && close
253                .participants
254                .iter()
255                .any(|participant| participant.registration == self.registration)
256            && self.verify_by(&author.device_signing_pubkey).is_ok()
257    }
258
259    pub(crate) fn response_hash(&self) -> ObjectHash {
260        self.hash()
261    }
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
265#[serde(deny_unknown_fields)]
266pub struct CircleEpochCloseResponseRef {
267    pub registration: StoreDeviceRegistrationRef,
268    pub frontier: CommitFrontier,
269    pub response_hash: ObjectHash,
270    pub object: ExactObjectRef,
271}
272
273impl CircleEpochCloseResponseRef {
274    pub fn from_response(
275        response: &CircleEpochCloseResponse,
276        object: ExactObjectRef,
277    ) -> Result<Self, CircleTransitionError> {
278        if object.slot().logical_key()
279            != format!(
280                "{}.json",
281                circle_epoch_close_response_semantic_prefix(
282                    response.circle_id,
283                    response.close_id,
284                    response.registration.device_id,
285                )
286            )
287        {
288            return Err(CircleTransitionError::InvalidCurrentState);
289        }
290        Ok(Self {
291            registration: response.registration.clone(),
292            frontier: response.frontier.clone(),
293            response_hash: response.response_hash(),
294            object,
295        })
296    }
297
298    pub(crate) fn verify_response(&self, response: &CircleEpochCloseResponse) -> bool {
299        self.registration == response.registration
300            && self.frontier == response.frontier
301            && self.response_hash == response.response_hash()
302    }
303}
304
305/// One Owner-signed exclusion of an unavailable participant device. It competes
306/// at that device's create-once response slot; activating it excludes the device
307/// from the close cutoff and forces it to reset from the successor bootstrap.
308/// The wire body of an Owner's exclusion of an unavailable participant. Every
309/// field here is signed.
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(deny_unknown_fields)]
312pub struct CircleEpochCloseExclusionBody {
313    pub store_root_hash: ObjectHash,
314    pub circle_id: CircleId,
315    pub close_id: CircleEpochCloseId,
316    pub close_control: CircleControlCoord,
317    pub excluded: StoreDeviceRegistrationRef,
318    pub owner_pubkey: String,
319}
320
321impl SignedBody for CircleEpochCloseExclusionBody {
322    const DOMAIN: &'static [u8] = CLOSE_EXCLUSION_DOMAIN;
323}
324
325pub type CircleEpochCloseExclusion = Signed<CircleEpochCloseExclusionBody>;
326
327impl CircleEpochCloseExclusion {
328    pub fn signed(
329        control: &PreparedCircleControl,
330        excluded: StoreDeviceRegistrationRef,
331        signer: &dyn coven_keys::keys::IdentityKeyAuthority,
332    ) -> Result<Self, CircleTransitionError> {
333        let CircleControlState::EpochClose(close) = control.value.state() else {
334            return Err(CircleTransitionError::InvalidCurrentState);
335        };
336        let exclusion = Signed::sign(
337            CircleEpochCloseExclusionBody {
338                store_root_hash: control.value.store_root_hash,
339                circle_id: control.value.circle_id,
340                close_id: close.close_id,
341                close_control: control.coord.clone(),
342                excluded,
343                owner_pubkey: keys::public_key_hex(signer),
344            },
345            signer,
346        );
347        if !exclusion.verify_shape(control) {
348            return Err(CircleTransitionError::InvalidCurrentState);
349        }
350        Ok(exclusion)
351    }
352
353    pub(super) fn verify_shape(&self, control: &PreparedCircleControl) -> bool {
354        let CircleControlState::EpochClose(close) = control.value.state() else {
355            return false;
356        };
357        control.verify()
358            && self.store_root_hash == control.value.store_root_hash
359            && self.circle_id == control.value.circle_id
360            && self.close_id == close.close_id
361            && self.close_control == control.coord
362            && close
363                .participants
364                .iter()
365                .any(|participant| participant.registration == self.excluded)
366            && close
367                .frozen_epoch
368                .common
369                .owners
370                .contains(&self.owner_pubkey)
371    }
372
373    pub fn verify_for(&self, control: &PreparedCircleControl) -> bool {
374        self.verify_shape(control) && self.verify_by(&self.owner_pubkey).is_ok()
375    }
376
377    pub(crate) fn exclusion_hash(&self) -> ObjectHash {
378        self.hash()
379    }
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
383#[serde(deny_unknown_fields)]
384pub struct CircleEpochCloseExclusionRef {
385    pub registration: StoreDeviceRegistrationRef,
386    pub exclusion_hash: ObjectHash,
387    pub object: ExactObjectRef,
388}
389
390impl CircleEpochCloseExclusionRef {
391    pub fn from_exclusion(
392        exclusion: &CircleEpochCloseExclusion,
393        object: ExactObjectRef,
394    ) -> Result<Self, CircleTransitionError> {
395        if object.slot().logical_key()
396            != format!(
397                "{}.json",
398                circle_epoch_close_response_semantic_prefix(
399                    exclusion.circle_id,
400                    exclusion.close_id,
401                    exclusion.excluded.device_id,
402                )
403            )
404        {
405            return Err(CircleTransitionError::InvalidCurrentState);
406        }
407        Ok(Self {
408            registration: exclusion.excluded.clone(),
409            exclusion_hash: exclusion.exclusion_hash(),
410            object,
411        })
412    }
413
414    pub(crate) fn verify_exclusion(&self, exclusion: &CircleEpochCloseExclusion) -> bool {
415        self.registration == exclusion.excluded && self.exclusion_hash == exclusion.exclusion_hash()
416    }
417}
418
419/// The exactly-one value a participant's create-once close-response slot holds:
420/// the device's own signed response, or an Owner-signed exclusion of that device.
421#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
422#[serde(rename_all = "snake_case", deny_unknown_fields)]
423pub enum CircleEpochCloseResponseSlotValue {
424    Response(CircleEpochCloseResponse),
425    Exclusion(CircleEpochCloseExclusion),
426}
427
428impl CircleEpochCloseResponseSlotValue {
429    pub fn to_bytes(&self) -> Vec<u8> {
430        serde_json::to_vec(self)
431            .expect("Circle epoch-close response slot value serialization cannot fail")
432    }
433
434    pub fn parse(bytes: &[u8]) -> Result<Self, CircleTransitionError> {
435        let value: Self = serde_json::from_slice(bytes)
436            .map_err(|_| CircleTransitionError::InvalidCurrentState)?;
437        if value.to_bytes() != bytes {
438            return Err(CircleTransitionError::InvalidCurrentState);
439        }
440        Ok(value)
441    }
442}
443
444/// One participant's contribution to a close outcome: either its verified device
445/// response (whose frontier joins the cutoff) or an Owner exclusion (which does
446/// not). The outcome carries one per participant.
447#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
448#[serde(rename_all = "snake_case", deny_unknown_fields)]
449pub enum CircleEpochCloseSettlement {
450    Response(CircleEpochCloseResponseRef),
451    Exclusion(CircleEpochCloseExclusionRef),
452}
453
454impl CircleEpochCloseSettlement {
455    pub fn registration(&self) -> &StoreDeviceRegistrationRef {
456        match self {
457            Self::Response(reference) => &reference.registration,
458            Self::Exclusion(reference) => &reference.registration,
459        }
460    }
461
462    pub fn object(&self) -> &ExactObjectRef {
463        match self {
464            Self::Response(reference) => &reference.object,
465            Self::Exclusion(reference) => &reference.object,
466        }
467    }
468
469    pub fn response_frontier(&self) -> Option<&CommitFrontier> {
470        match self {
471            Self::Response(reference) => Some(&reference.frontier),
472            Self::Exclusion(_) => None,
473        }
474    }
475}
476
477#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
478#[serde(deny_unknown_fields)]
479pub struct CircleEpochSuccessor {
480    pub epoch_id: CircleEpochId,
481    pub key_fingerprint: KeyFingerprint,
482    pub owners: Vec<String>,
483    pub access_root: ObjectHash,
484    pub metadata: MergeCircleMetadataStateRef,
485    pub roster: MergeCircleRosterStateRef,
486    pub store_membership: StoreMembershipStateRef,
487}
488
489/// The wire body of an epoch close's outcome. Every field here is signed.
490#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
491#[serde(deny_unknown_fields)]
492pub struct CircleEpochCloseOutcomeBody {
493    pub store_root_hash: ObjectHash,
494    pub circle_id: CircleId,
495    pub close_id: CircleEpochCloseId,
496    pub close_control: CircleControlCoord,
497    pub intent: CircleEpochCloseIntentRef,
498    pub responses: Vec<CircleEpochCloseSettlement>,
499    pub cutoff: CommitFrontier,
500    pub successor: CircleEpochSuccessor,
501    pub owner_pubkey: String,
502}
503
504impl SignedBody for CircleEpochCloseOutcomeBody {
505    const DOMAIN: &'static [u8] = CLOSE_OUTCOME_DOMAIN;
506}
507
508pub type CircleEpochCloseOutcome = Signed<CircleEpochCloseOutcomeBody>;
509
510impl CircleEpochCloseOutcome {
511    pub fn signed(
512        control: &PreparedCircleControl,
513        intent: &CircleEpochCloseIntent,
514        responses: Vec<CircleEpochCloseSettlement>,
515        successor: CircleEpochSuccessor,
516        signer: &dyn coven_keys::keys::IdentityKeyAuthority,
517    ) -> Result<Self, CircleTransitionError> {
518        let CircleControlState::EpochClose(close) = control.value.state() else {
519            return Err(CircleTransitionError::InvalidCurrentState);
520        };
521        let cutoff = responses
522            .iter()
523            .filter_map(CircleEpochCloseSettlement::response_frontier)
524            .try_fold(close.provisional_frontier.clone(), |cutoff, frontier| {
525                cutoff.join(frontier.clone())
526            })
527            .map_err(|_| CircleTransitionError::InvalidCurrentState)?;
528        let outcome = Signed::sign(
529            CircleEpochCloseOutcomeBody {
530                store_root_hash: control.value.store_root_hash,
531                circle_id: control.value.circle_id,
532                close_id: close.close_id,
533                close_control: control.coord.clone(),
534                intent: close.intent.clone(),
535                responses,
536                cutoff,
537                successor,
538                owner_pubkey: keys::public_key_hex(signer),
539            },
540            signer,
541        );
542        if !outcome.verify_shape(control) || !outcome.verify_intent(intent) {
543            return Err(CircleTransitionError::InvalidCurrentState);
544        }
545        Ok(outcome)
546    }
547
548    pub(super) fn verify_shape(&self, control: &PreparedCircleControl) -> bool {
549        let CircleControlState::EpochClose(close) = control.value.state() else {
550            return false;
551        };
552        let responses_are_canonical = self
553            .responses
554            .windows(2)
555            .all(|pair| pair[0].registration().device_id < pair[1].registration().device_id);
556        let responses_match_participants =
557            self.responses.len() == close.participants.len()
558                && self.responses.iter().zip(&close.participants).all(
559                    |(settlement, participant)| {
560                        settlement.registration() == &participant.registration
561                            && settlement.object().slot() == &participant.response_slot
562                            && settlement
563                                .response_frontier()
564                                .is_none_or(|frontier| frontier.covers(&close.provisional_frontier))
565                    },
566                );
567        let expected_cutoff = self
568            .responses
569            .iter()
570            .filter_map(CircleEpochCloseSettlement::response_frontier)
571            .try_fold(close.provisional_frontier.clone(), |cutoff, frontier| {
572                cutoff.join(frontier.clone())
573            });
574        control.verify()
575            && self.store_root_hash == control.value.store_root_hash
576            && self.circle_id == control.value.circle_id
577            && self.close_id == close.close_id
578            && self.close_control == control.coord
579            && self.intent == close.intent
580            && responses_are_canonical
581            && responses_match_participants
582            && expected_cutoff.is_ok_and(|cutoff| cutoff == self.cutoff)
583            && crate::store_commit::validate_commit_frontier(&self.cutoff).is_ok()
584            && self.successor.epoch_id != close.frozen_epoch.common.epoch_id
585            && self.successor.key_fingerprint != close.frozen_epoch.common.key_fingerprint
586            && !self.successor.owners.is_empty()
587            && self
588                .successor
589                .owners
590                .windows(2)
591                .all(|pair| pair[0] < pair[1])
592            && close
593                .frozen_epoch
594                .common
595                .owners
596                .contains(&self.owner_pubkey)
597    }
598
599    fn verify_intent(&self, intent: &CircleEpochCloseIntent) -> bool {
600        intent.verify()
601            && intent.close_id == self.close_id
602            && intent.circle_id == self.circle_id
603            && intent.store_root_hash == self.store_root_hash
604            && intent.intent_hash() == self.intent.intent_hash
605            && self.successor.roster.state_hash == intent.remaining_roster_state_hash
606    }
607
608    pub fn verify_for(
609        &self,
610        control: &PreparedCircleControl,
611        intent: &CircleEpochCloseIntent,
612        settlements: &[(
613            CircleEpochCloseSettlement,
614            CircleEpochCloseResponseSlotValue,
615        )],
616    ) -> bool {
617        self.verify_shape(control)
618            && self.verify_intent(intent)
619            && self.responses.len() == settlements.len()
620            && self
621                .responses
622                .iter()
623                .zip(settlements)
624                .all(|(expected, (settlement, slot_value))| {
625                    expected == settlement
626                        && match (settlement, slot_value) {
627                            (
628                                CircleEpochCloseSettlement::Response(reference),
629                                CircleEpochCloseResponseSlotValue::Response(response),
630                            ) => {
631                                reference.verify_response(response)
632                                    && response.close_control == self.close_control
633                            }
634                            (
635                                CircleEpochCloseSettlement::Exclusion(reference),
636                                CircleEpochCloseResponseSlotValue::Exclusion(exclusion),
637                            ) => {
638                                reference.verify_exclusion(exclusion)
639                                    && exclusion.verify_for(control)
640                                    && exclusion.close_control == self.close_control
641                            }
642                            _ => false,
643                        }
644                })
645            && self.verify_signature()
646    }
647
648    pub fn verify_signature(&self) -> bool {
649        self.verify_by(&self.owner_pubkey).is_ok()
650    }
651
652    pub fn outcome_hash(&self) -> ObjectHash {
653        self.hash()
654    }
655}
656
657#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
658#[serde(deny_unknown_fields)]
659pub struct CircleEpochCloseOutcomeRef {
660    pub close_id: CircleEpochCloseId,
661    pub outcome_hash: ObjectHash,
662    pub object: ExactObjectRef,
663}
664
665impl CircleEpochCloseOutcomeRef {
666    pub fn from_outcome(
667        outcome: &CircleEpochCloseOutcome,
668        object: ExactObjectRef,
669    ) -> Result<Self, CircleTransitionError> {
670        if object.slot().logical_key()
671            != format!(
672                "{}.json",
673                circle_epoch_close_outcome_semantic_prefix(outcome.circle_id, outcome.close_id)
674            )
675        {
676            return Err(CircleTransitionError::InvalidCurrentState);
677        }
678        Ok(Self {
679            close_id: outcome.close_id,
680            outcome_hash: outcome.outcome_hash(),
681            object,
682        })
683    }
684}
685
686/// One Owner-signed cancellation of an epoch close. It competes at the same
687/// create-once outcome slot as the final outcome; activating it reopens the
688/// frozen epoch instead of rotating to a successor epoch.
689/// The wire body of an Owner's cancellation of an epoch close. Every field here
690/// is signed.
691#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
692#[serde(deny_unknown_fields)]
693pub struct CircleEpochCloseCancellationBody {
694    pub store_root_hash: ObjectHash,
695    pub circle_id: CircleId,
696    pub close_id: CircleEpochCloseId,
697    pub close_control: CircleControlCoord,
698    pub intent: CircleEpochCloseIntentRef,
699    pub owner_pubkey: String,
700}
701
702impl SignedBody for CircleEpochCloseCancellationBody {
703    const DOMAIN: &'static [u8] = CLOSE_CANCELLATION_DOMAIN;
704}
705
706pub type CircleEpochCloseCancellation = Signed<CircleEpochCloseCancellationBody>;
707
708impl CircleEpochCloseCancellation {
709    pub fn signed(
710        control: &PreparedCircleControl,
711        signer: &dyn coven_keys::keys::IdentityKeyAuthority,
712    ) -> Result<Self, CircleTransitionError> {
713        let CircleControlState::EpochClose(close) = control.value.state() else {
714            return Err(CircleTransitionError::InvalidCurrentState);
715        };
716        let cancellation = Signed::sign(
717            CircleEpochCloseCancellationBody {
718                store_root_hash: control.value.store_root_hash,
719                circle_id: control.value.circle_id,
720                close_id: close.close_id,
721                close_control: control.coord.clone(),
722                intent: close.intent.clone(),
723                owner_pubkey: keys::public_key_hex(signer),
724            },
725            signer,
726        );
727        if !cancellation.verify_shape(control) {
728            return Err(CircleTransitionError::InvalidCurrentState);
729        }
730        Ok(cancellation)
731    }
732
733    pub(super) fn verify_shape(&self, control: &PreparedCircleControl) -> bool {
734        let CircleControlState::EpochClose(close) = control.value.state() else {
735            return false;
736        };
737        control.verify()
738            && self.store_root_hash == control.value.store_root_hash
739            && self.circle_id == control.value.circle_id
740            && self.close_id == close.close_id
741            && self.close_control == control.coord
742            && self.intent == close.intent
743            && close
744                .frozen_epoch
745                .common
746                .owners
747                .contains(&self.owner_pubkey)
748    }
749
750    pub fn verify_for(&self, control: &PreparedCircleControl) -> bool {
751        self.verify_shape(control) && self.verify_signature()
752    }
753
754    pub fn verify_signature(&self) -> bool {
755        self.verify_by(&self.owner_pubkey).is_ok()
756    }
757
758    pub fn cancellation_hash(&self) -> ObjectHash {
759        self.hash()
760    }
761}
762
763#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
764#[serde(deny_unknown_fields)]
765pub struct CircleEpochCloseCancellationRef {
766    pub close_id: CircleEpochCloseId,
767    pub cancellation_hash: ObjectHash,
768    pub object: ExactObjectRef,
769}
770
771impl CircleEpochCloseCancellationRef {
772    pub fn from_cancellation(
773        cancellation: &CircleEpochCloseCancellation,
774        object: ExactObjectRef,
775    ) -> Result<Self, CircleTransitionError> {
776        if object.slot().logical_key()
777            != format!(
778                "{}.json",
779                circle_epoch_close_outcome_semantic_prefix(
780                    cancellation.circle_id,
781                    cancellation.close_id
782                )
783            )
784        {
785            return Err(CircleTransitionError::InvalidCurrentState);
786        }
787        Ok(Self {
788            close_id: cancellation.close_id,
789            cancellation_hash: cancellation.cancellation_hash(),
790            object,
791        })
792    }
793}
794
795/// The exactly-one value the create-once epoch-close outcome slot holds. Readers
796/// parse this tagged form and dispatch on the settled arm: a final outcome
797/// rotates to a successor epoch, a cancellation reopens the frozen epoch.
798#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
799#[serde(rename_all = "snake_case", deny_unknown_fields)]
800pub enum CircleEpochCloseSlotValue {
801    Outcome(CircleEpochCloseOutcome),
802    Cancellation(CircleEpochCloseCancellation),
803}
804
805impl CircleEpochCloseSlotValue {
806    pub fn to_bytes(&self) -> Vec<u8> {
807        serde_json::to_vec(self).expect("Circle epoch-close slot value serialization cannot fail")
808    }
809
810    pub fn parse(bytes: &[u8]) -> Result<Self, CircleTransitionError> {
811        let value: Self = serde_json::from_slice(bytes)
812            .map_err(|_| CircleTransitionError::InvalidCurrentState)?;
813        if value.to_bytes() != bytes {
814            return Err(CircleTransitionError::InvalidCurrentState);
815        }
816        Ok(value)
817    }
818}