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}