Skip to main content

chio_open_market/
evaluation.rs

1use serde::{Deserialize, Serialize};
2
3use crate::authority::{
4    ensure_open_market_evaluation_authority_signers, verify_signed_activation, verify_signed_case,
5    verify_signed_charter, verify_signed_fee_schedule, verify_signed_listing,
6    verify_signed_penalty,
7};
8use crate::capability::scope::MonetaryAmount;
9use crate::crypto::PublicKey;
10use crate::evidence::{OpenMarketFinding, OpenMarketFindingCode};
11use crate::fee_schedule::{
12    OpenMarketBondRequirement, OpenMarketFeeScheduleArtifact, SignedOpenMarketFeeSchedule,
13};
14use crate::governance::generic::{
15    GenericGovernanceCaseKind, GenericGovernanceCaseState, SignedGenericGovernanceCase,
16    SignedGenericGovernanceCharter,
17};
18use crate::listing::{
19    normalize_namespace, GenericRegistryPublisher, SignedGenericListing,
20    SignedGenericTrustActivation,
21};
22use crate::penalty::{
23    OpenMarketPenaltyAction, OpenMarketPenaltyEffectiveState, OpenMarketPenaltyState,
24    SignedOpenMarketPenalty,
25};
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
28#[serde(rename_all = "camelCase")]
29pub struct OpenMarketPenaltyEvaluationRequest {
30    pub fee_schedule: SignedOpenMarketFeeSchedule,
31    pub listing: SignedGenericListing,
32    pub current_publisher: GenericRegistryPublisher,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub activation: Option<SignedGenericTrustActivation>,
35    pub charter: SignedGenericGovernanceCharter,
36    pub case: SignedGenericGovernanceCase,
37    pub penalty: SignedOpenMarketPenalty,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub prior_penalty: Option<SignedOpenMarketPenalty>,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub evaluated_at: Option<u64>,
42}
43
44impl OpenMarketPenaltyEvaluationRequest {
45    pub fn validate(&self) -> Result<(), String> {
46        self.listing.body.validate()?;
47        self.current_publisher.validate()?;
48        Ok(())
49    }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53#[serde(rename_all = "camelCase")]
54pub struct OpenMarketPenaltyEvaluation {
55    pub listing_id: String,
56    pub namespace: String,
57    pub fee_schedule_id: String,
58    pub charter_id: String,
59    pub case_id: String,
60    pub penalty_id: String,
61    pub governing_operator_id: String,
62    pub action: OpenMarketPenaltyAction,
63    pub state: OpenMarketPenaltyState,
64    pub effective_state: OpenMarketPenaltyEffectiveState,
65    pub evaluated_at: u64,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub publication_fee: Option<MonetaryAmount>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub dispute_fee: Option<MonetaryAmount>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub market_participation_fee: Option<MonetaryAmount>,
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub bond_requirement: Option<OpenMarketBondRequirement>,
74    pub blocks_admission: bool,
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub findings: Vec<OpenMarketFinding>,
77}
78
79pub fn evaluate_open_market_penalty(
80    request: &OpenMarketPenaltyEvaluationRequest,
81    now: u64,
82    trusted_local_operator_signer: &PublicKey,
83) -> Result<OpenMarketPenaltyEvaluation, String> {
84    evaluate_open_market_penalty_with_trusted_signers(
85        request,
86        now,
87        std::slice::from_ref(trusted_local_operator_signer),
88    )
89}
90
91pub fn evaluate_open_market_penalty_with_trusted_signers(
92    request: &OpenMarketPenaltyEvaluationRequest,
93    now: u64,
94    trusted_local_operator_signers: &[PublicKey],
95) -> Result<OpenMarketPenaltyEvaluation, String> {
96    request.validate()?;
97    let evaluated_at = request.evaluated_at.unwrap_or(now);
98
99    if let Err(error) = verify_signed_listing(&request.listing, "penalty listing") {
100        return Ok(open_market_failure(
101            request,
102            evaluated_at,
103            OpenMarketFindingCode::ListingUnverifiable,
104            &error,
105            None,
106        ));
107    }
108    if let Err(error) = verify_signed_fee_schedule(&request.fee_schedule) {
109        return Ok(open_market_failure(
110            request,
111            evaluated_at,
112            OpenMarketFindingCode::FeeScheduleUnverifiable,
113            &error,
114            None,
115        ));
116    }
117    if let Some(activation) = request.activation.as_ref() {
118        if let Err(error) = verify_signed_activation(activation) {
119            return Ok(open_market_failure(
120                request,
121                evaluated_at,
122                OpenMarketFindingCode::ActivationUnverifiable,
123                &error,
124                Some(&request.fee_schedule.body),
125            ));
126        }
127    }
128    if let Err(error) = verify_signed_charter(&request.charter) {
129        return Ok(open_market_failure(
130            request,
131            evaluated_at,
132            OpenMarketFindingCode::GovernanceCaseAuthorityInvalid,
133            &error,
134            Some(&request.fee_schedule.body),
135        ));
136    }
137    if let Err(error) = verify_signed_case(&request.case) {
138        return Ok(open_market_failure(
139            request,
140            evaluated_at,
141            OpenMarketFindingCode::GovernanceCaseAuthorityInvalid,
142            &error,
143            Some(&request.fee_schedule.body),
144        ));
145    }
146    if let Err(error) = verify_signed_penalty(&request.penalty) {
147        return Ok(open_market_failure(
148            request,
149            evaluated_at,
150            OpenMarketFindingCode::PenaltyUnverifiable,
151            &error,
152            Some(&request.fee_schedule.body),
153        ));
154    }
155    if let Some(prior_penalty) = request.prior_penalty.as_ref() {
156        if let Err(error) = verify_signed_penalty(prior_penalty) {
157            return Ok(open_market_failure(
158                request,
159                evaluated_at,
160                OpenMarketFindingCode::PriorPenaltyInvalid,
161                &error,
162                Some(&request.fee_schedule.body),
163            ));
164        }
165    }
166    if let Err(error) =
167        ensure_open_market_evaluation_authority_signers(request, trusted_local_operator_signers)
168    {
169        return Ok(open_market_failure(
170            request,
171            evaluated_at,
172            OpenMarketFindingCode::GovernanceCaseAuthorityInvalid,
173            &error,
174            Some(&request.fee_schedule.body),
175        ));
176    }
177
178    let listing = &request.listing.body;
179    let fee_schedule = &request.fee_schedule.body;
180    let charter = &request.charter.body;
181    let governance_case = &request.case.body;
182    let penalty = &request.penalty.body;
183    let namespace = normalize_namespace(&listing.namespace);
184
185    if let Some(activation) = request.activation.as_ref() {
186        if activation.body.local_operator_id != fee_schedule.governing_operator_id {
187            return Ok(open_market_failure(
188                request,
189                evaluated_at,
190                OpenMarketFindingCode::ActivationMismatch,
191                "open-market penalties require a trust activation issued by the governing operator",
192                Some(fee_schedule),
193            ));
194        }
195    }
196
197    if normalize_namespace(&fee_schedule.namespace) != namespace
198        || normalize_namespace(&fee_schedule.scope.namespace) != namespace
199    {
200        return Ok(open_market_failure(
201            request,
202            evaluated_at,
203            OpenMarketFindingCode::FeeScheduleScopeMismatch,
204            "fee schedule namespace does not match the current listing namespace",
205            Some(fee_schedule),
206        ));
207    }
208    if normalize_namespace(&charter.authority_scope.namespace) != namespace
209        || normalize_namespace(&governance_case.namespace) != namespace
210        || normalize_namespace(&penalty.namespace) != namespace
211        || governance_case.listing_id != listing.listing_id
212        || penalty.listing_id != listing.listing_id
213        || penalty.case_id != governance_case.case_id
214        || penalty.charter_id != charter.charter_id
215        || penalty.fee_schedule_id != fee_schedule.fee_schedule_id
216    {
217        return Ok(open_market_failure(
218            request,
219            evaluated_at,
220            OpenMarketFindingCode::GovernanceCaseAuthorityInvalid,
221            "governance or penalty authority does not match the current listing, namespace, or fee schedule",
222            Some(fee_schedule),
223        ));
224    }
225    if fee_schedule.governing_operator_id != charter.governing_operator_id
226        || fee_schedule.governing_operator_id != governance_case.governing_operator_id
227        || fee_schedule.governing_operator_id != penalty.governing_operator_id
228    {
229        return Ok(open_market_failure(
230            request,
231            evaluated_at,
232            OpenMarketFindingCode::GovernanceCaseAuthorityInvalid,
233            "fee schedule, governance, and penalty operators must match",
234            Some(fee_schedule),
235        ));
236    }
237
238    if fee_schedule
239        .expires_at
240        .is_some_and(|expires_at| expires_at <= evaluated_at)
241    {
242        return Ok(open_market_failure(
243            request,
244            evaluated_at,
245            OpenMarketFindingCode::FeeScheduleExpired,
246            "open-market fee schedule has expired",
247            Some(fee_schedule),
248        ));
249    }
250    if charter
251        .expires_at
252        .is_some_and(|expires_at| expires_at <= evaluated_at)
253        || governance_case
254            .expires_at
255            .is_some_and(|expires_at| expires_at <= evaluated_at)
256    {
257        return Ok(open_market_failure(
258            request,
259            evaluated_at,
260            OpenMarketFindingCode::GovernanceCaseExpired,
261            "governance authority has expired",
262            Some(fee_schedule),
263        ));
264    }
265    if penalty
266        .expires_at
267        .is_some_and(|expires_at| expires_at <= evaluated_at)
268    {
269        return Ok(open_market_failure(
270            request,
271            evaluated_at,
272            OpenMarketFindingCode::PenaltyExpired,
273            "open-market penalty has expired",
274            Some(fee_schedule),
275        ));
276    }
277    if !fee_schedule.scope.allowed_listing_operator_ids.is_empty()
278        && !fee_schedule
279            .scope
280            .allowed_listing_operator_ids
281            .contains(&request.current_publisher.operator_id)
282    {
283        return Ok(open_market_failure(
284            request,
285            evaluated_at,
286            OpenMarketFindingCode::FeeScheduleScopeMismatch,
287            "current listing publisher falls outside the fee schedule scope",
288            Some(fee_schedule),
289        ));
290    }
291    if !fee_schedule.scope.allowed_actor_kinds.is_empty()
292        && !fee_schedule
293            .scope
294            .allowed_actor_kinds
295            .contains(&listing.subject.actor_kind)
296    {
297        return Ok(open_market_failure(
298            request,
299            evaluated_at,
300            OpenMarketFindingCode::FeeScheduleScopeMismatch,
301            "listing actor kind falls outside the fee schedule scope",
302            Some(fee_schedule),
303        ));
304    }
305    if !fee_schedule.scope.allowed_admission_classes.is_empty() {
306        let Some(activation) = request.activation.as_ref() else {
307            return Ok(open_market_failure(
308                request,
309                evaluated_at,
310                OpenMarketFindingCode::ActivationMissing,
311                "fee schedule requires an explicit trust activation class",
312                Some(fee_schedule),
313            ));
314        };
315        if governance_case.activation_id.as_deref() != Some(activation.body.activation_id.as_str())
316            || penalty.activation_id.as_deref() != Some(activation.body.activation_id.as_str())
317        {
318            return Ok(open_market_failure(
319                request,
320                evaluated_at,
321                OpenMarketFindingCode::ActivationMismatch,
322                "governance case or penalty activation does not match the current trust activation",
323                Some(fee_schedule),
324            ));
325        }
326        if !fee_schedule
327            .scope
328            .allowed_admission_classes
329            .contains(&activation.body.admission_class)
330        {
331            return Ok(open_market_failure(
332                request,
333                evaluated_at,
334                OpenMarketFindingCode::ActivationMismatch,
335                "trust activation admission class falls outside the fee schedule scope",
336                Some(fee_schedule),
337            ));
338        }
339    }
340
341    let bond_requirement = fee_schedule
342        .bond_requirements
343        .iter()
344        .find(|requirement| requirement.bond_class == penalty.bond_class)
345        .cloned();
346    let Some(bond_requirement) = bond_requirement else {
347        return Ok(open_market_failure(
348            request,
349            evaluated_at,
350            OpenMarketFindingCode::BondRequirementMissing,
351            "fee schedule does not define the required bond class for this penalty",
352            Some(fee_schedule),
353        ));
354    };
355
356    match penalty.action {
357        OpenMarketPenaltyAction::HoldBond | OpenMarketPenaltyAction::SlashBond => {
358            if !matches!(
359                (governance_case.kind, governance_case.state),
360                (
361                    GenericGovernanceCaseKind::Sanction,
362                    GenericGovernanceCaseState::Enforced
363                )
364            ) {
365                return Ok(open_market_failure(
366                    request,
367                    evaluated_at,
368                    OpenMarketFindingCode::GovernanceCaseKindInvalid,
369                    "bond hold or slash requires an enforced sanction case",
370                    Some(fee_schedule),
371                ));
372            }
373            if matches!(penalty.action, OpenMarketPenaltyAction::SlashBond)
374                && !bond_requirement.slashable
375            {
376                return Ok(open_market_failure(
377                    request,
378                    evaluated_at,
379                    OpenMarketFindingCode::BondRequirementNotSlashable,
380                    "selected bond requirement is not slashable",
381                    Some(fee_schedule),
382                ));
383            }
384        }
385        OpenMarketPenaltyAction::ReverseSlash => {
386            if !matches!(governance_case.kind, GenericGovernanceCaseKind::Appeal) {
387                return Ok(open_market_failure(
388                    request,
389                    evaluated_at,
390                    OpenMarketFindingCode::GovernanceCaseKindInvalid,
391                    "reverse slash requires an appeal governance case",
392                    Some(fee_schedule),
393                ));
394            }
395            let Some(prior_penalty) = request.prior_penalty.as_ref() else {
396                return Ok(open_market_failure(
397                    request,
398                    evaluated_at,
399                    OpenMarketFindingCode::PriorPenaltyMissing,
400                    "reverse slash requires prior_penalty",
401                    Some(fee_schedule),
402                ));
403            };
404            let Some(supersedes_penalty_id) = penalty.supersedes_penalty_id.as_deref() else {
405                return Ok(open_market_failure(
406                    request,
407                    evaluated_at,
408                    OpenMarketFindingCode::PriorPenaltyInvalid,
409                    "reverse slash must reference the prior penalty id",
410                    Some(fee_schedule),
411                ));
412            };
413            if prior_penalty.body.penalty_id != supersedes_penalty_id
414                || prior_penalty.body.listing_id != listing.listing_id
415                || prior_penalty.body.fee_schedule_id != fee_schedule.fee_schedule_id
416                || prior_penalty.body.bond_class != penalty.bond_class
417                || !matches!(
418                    prior_penalty.body.action,
419                    OpenMarketPenaltyAction::HoldBond | OpenMarketPenaltyAction::SlashBond
420                )
421                || !matches!(prior_penalty.body.state, OpenMarketPenaltyState::Enforced)
422            {
423                return Ok(open_market_failure(
424                    request,
425                    evaluated_at,
426                    OpenMarketFindingCode::PriorPenaltyInvalid,
427                    "prior penalty does not match the reverse-slash target",
428                    Some(fee_schedule),
429                ));
430            }
431        }
432    }
433
434    if bond_requirement.required_amount.currency != penalty.penalty_amount.currency {
435        return Ok(open_market_failure(
436            request,
437            evaluated_at,
438            OpenMarketFindingCode::PenaltyCurrencyMismatch,
439            "penalty currency must match the configured bond currency",
440            Some(fee_schedule),
441        ));
442    }
443    if penalty.penalty_amount.units > bond_requirement.required_amount.units {
444        return Ok(open_market_failure(
445            request,
446            evaluated_at,
447            OpenMarketFindingCode::PenaltyAmountExceedsBond,
448            "penalty amount exceeds the configured bond requirement",
449            Some(fee_schedule),
450        ));
451    }
452
453    let (effective_state, blocks_admission) =
454        open_market_effective_state(penalty.action, penalty.state);
455
456    Ok(OpenMarketPenaltyEvaluation {
457        listing_id: listing.listing_id.clone(),
458        namespace,
459        fee_schedule_id: fee_schedule.fee_schedule_id.clone(),
460        charter_id: charter.charter_id.clone(),
461        case_id: governance_case.case_id.clone(),
462        penalty_id: penalty.penalty_id.clone(),
463        governing_operator_id: penalty.governing_operator_id.clone(),
464        action: penalty.action,
465        state: penalty.state,
466        effective_state,
467        evaluated_at,
468        publication_fee: Some(fee_schedule.publication_fee.clone()),
469        dispute_fee: Some(fee_schedule.dispute_fee.clone()),
470        market_participation_fee: Some(fee_schedule.market_participation_fee.clone()),
471        bond_requirement: Some(bond_requirement),
472        blocks_admission,
473        findings: Vec::new(),
474    })
475}
476
477fn open_market_effective_state(
478    action: OpenMarketPenaltyAction,
479    state: OpenMarketPenaltyState,
480) -> (OpenMarketPenaltyEffectiveState, bool) {
481    match state {
482        OpenMarketPenaltyState::Proposed
483        | OpenMarketPenaltyState::Denied
484        | OpenMarketPenaltyState::Superseded => (OpenMarketPenaltyEffectiveState::Clear, false),
485        OpenMarketPenaltyState::Reversed => (OpenMarketPenaltyEffectiveState::Reversed, false),
486        OpenMarketPenaltyState::Enforced => match action {
487            OpenMarketPenaltyAction::HoldBond => (OpenMarketPenaltyEffectiveState::BondHeld, true),
488            OpenMarketPenaltyAction::SlashBond => {
489                (OpenMarketPenaltyEffectiveState::BondSlashed, true)
490            }
491            OpenMarketPenaltyAction::ReverseSlash => {
492                (OpenMarketPenaltyEffectiveState::Reversed, false)
493            }
494        },
495    }
496}
497
498fn open_market_failure(
499    request: &OpenMarketPenaltyEvaluationRequest,
500    evaluated_at: u64,
501    code: OpenMarketFindingCode,
502    message: &str,
503    fee_schedule: Option<&OpenMarketFeeScheduleArtifact>,
504) -> OpenMarketPenaltyEvaluation {
505    OpenMarketPenaltyEvaluation {
506        listing_id: request.listing.body.listing_id.clone(),
507        namespace: request.listing.body.namespace.clone(),
508        fee_schedule_id: request.penalty.body.fee_schedule_id.clone(),
509        charter_id: request.penalty.body.charter_id.clone(),
510        case_id: request.penalty.body.case_id.clone(),
511        penalty_id: request.penalty.body.penalty_id.clone(),
512        governing_operator_id: request.penalty.body.governing_operator_id.clone(),
513        action: request.penalty.body.action,
514        state: request.penalty.body.state,
515        effective_state: OpenMarketPenaltyEffectiveState::Clear,
516        evaluated_at,
517        publication_fee: fee_schedule.map(|schedule| schedule.publication_fee.clone()),
518        dispute_fee: fee_schedule.map(|schedule| schedule.dispute_fee.clone()),
519        market_participation_fee: fee_schedule
520            .map(|schedule| schedule.market_participation_fee.clone()),
521        bond_requirement: None,
522        blocks_admission: false,
523        findings: vec![OpenMarketFinding {
524            code,
525            message: message.to_string(),
526        }],
527    }
528}