1use std::collections::BTreeSet;
2
3use chio_core::receipt::lineage::SignedExportEnvelope;
4use chio_core::Keypair;
5use serde::{Deserialize, Serialize};
6
7pub const ECONSIM_SCENARIO_RESULT_SCHEMA: &str = "chio.econsim.scenario-result.v1";
8pub const ECONSIM_QUALIFICATION_MATRIX_SCHEMA: &str = "chio.econsim.qualification-matrix.v1";
9
10pub const ECONSIM_SCENARIO_CLASSES: [&str; 6] = [
11 "collusion-bid-ring",
12 "credit-exhaustion",
13 "fee-structuring",
14 "oracle-divergence",
15 "settlement-dos",
16 "sybil-pricing-ring",
17];
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum EconsimDisposition {
22 FailClosed,
23 Breach,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
27#[serde(rename_all = "kebab-case")]
28pub enum EconsimFindingSeverity {
29 Low,
30 Medium,
31 High,
32 Critical,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "kebab-case")]
37pub enum EconsimTargetStatus {
38 Bound,
39 Missing,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "kebab-case")]
44pub enum EconsimOutcome {
45 Held,
46 Finding,
47 TargetMissing,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase", deny_unknown_fields)]
52pub struct EconsimScenarioResult {
53 pub schema: String,
54 pub scenario_id: String,
55 pub scenario_class: String,
56 pub seed: u64,
57 pub corpus_manifest_digest: String,
58 pub requirement_ids: Vec<String>,
59 pub expected_disposition: EconsimDisposition,
60 pub observed_disposition: Option<EconsimDisposition>,
61 pub target_status: EconsimTargetStatus,
62 pub outcome: EconsimOutcome,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub finding_severity: Option<EconsimFindingSeverity>,
65 pub assertion_scope: String,
66 pub explicit_limits: Vec<String>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase", deny_unknown_fields)]
71pub struct EconsimHarnessProvenance {
72 pub git_commit: String,
73 pub source_tree_state: String,
74 pub source_tree_digest: String,
75 pub executable_digest: String,
76 pub cargo_lock_digest: String,
77 pub enabled_features: Vec<String>,
78 pub target_triple: String,
79 pub rustc_version: String,
80 pub cargo_version: String,
81 pub command: Vec<String>,
82 pub scenario_manifest_digest: String,
83 pub corpus_manifest_digest: String,
84 pub runner_key_id: String,
85 pub qualification_scope: String,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase", deny_unknown_fields)]
90pub struct EconsimQualificationMatrix {
91 pub schema: String,
92 pub profile_id: String,
93 pub harness_provenance: EconsimHarnessProvenance,
94 pub cases: Vec<EconsimScenarioResult>,
95}
96
97pub type SignedEconsimQualificationMatrix = SignedExportEnvelope<EconsimQualificationMatrix>;
98
99#[derive(Debug, thiserror::Error, PartialEq, Eq)]
100pub enum EconsimValidationError {
101 #[error("unsupported econsim schema: {0}")]
102 UnsupportedSchema(String),
103 #[error("econsim field must not be empty: {0}")]
104 EmptyField(&'static str),
105 #[error("econsim scenario seed must be non-zero")]
106 ZeroSeed,
107 #[error("unknown econsim scenario class: {0}")]
108 UnknownClass(String),
109 #[error("duplicate econsim scenario id: {0}")]
110 DuplicateScenario(String),
111 #[error("invalid econsim result: {0}")]
112 InvalidResult(&'static str),
113 #[error("econsim matrix is missing scenario class: {0}")]
114 MissingClass(String),
115 #[error("econsim matrix cannot be signed: {0}")]
116 SigningBoundary(&'static str),
117 #[error("econsim matrix signing failed: {0}")]
118 Signing(String),
119}
120
121pub fn validate_econsim_scenario_result(
122 result: &EconsimScenarioResult,
123) -> Result<(), EconsimValidationError> {
124 if result.schema != ECONSIM_SCENARIO_RESULT_SCHEMA {
125 return Err(EconsimValidationError::UnsupportedSchema(
126 result.schema.clone(),
127 ));
128 }
129 for (value, field) in [
130 (&result.scenario_id, "scenarioId"),
131 (&result.scenario_class, "scenarioClass"),
132 (&result.corpus_manifest_digest, "corpusManifestDigest"),
133 (&result.assertion_scope, "assertionScope"),
134 ] {
135 if value.trim().is_empty() {
136 return Err(EconsimValidationError::EmptyField(field));
137 }
138 }
139 if !ECONSIM_SCENARIO_CLASSES.contains(&result.scenario_class.as_str()) {
140 return Err(EconsimValidationError::UnknownClass(
141 result.scenario_class.clone(),
142 ));
143 }
144 if result.seed == 0 {
145 return Err(EconsimValidationError::ZeroSeed);
146 }
147 if result.requirement_ids.is_empty() {
148 return Err(EconsimValidationError::EmptyField("requirementIds"));
149 }
150 if result.explicit_limits.is_empty() {
151 return Err(EconsimValidationError::EmptyField("explicitLimits"));
152 }
153 if result.expected_disposition != EconsimDisposition::FailClosed {
154 return Err(EconsimValidationError::InvalidResult(
155 "expected disposition must be fail-closed",
156 ));
157 }
158 match result.outcome {
159 EconsimOutcome::Held
160 if result.target_status == EconsimTargetStatus::Bound
161 && result.observed_disposition == Some(EconsimDisposition::FailClosed)
162 && result.finding_severity.is_none() => {}
163 EconsimOutcome::Finding
164 if result.target_status == EconsimTargetStatus::Bound
165 && result.observed_disposition == Some(EconsimDisposition::Breach)
166 && result.finding_severity.is_some() => {}
167 EconsimOutcome::TargetMissing
168 if result.target_status == EconsimTargetStatus::Missing
169 && result.observed_disposition.is_none()
170 && result.finding_severity == Some(EconsimFindingSeverity::High) => {}
171 _ => {
172 return Err(EconsimValidationError::InvalidResult(
173 "outcome, target, disposition, and severity disagree",
174 ));
175 }
176 }
177 Ok(())
178}
179
180pub fn validate_econsim_qualification_matrix(
181 matrix: &EconsimQualificationMatrix,
182) -> Result<(), EconsimValidationError> {
183 if matrix.schema != ECONSIM_QUALIFICATION_MATRIX_SCHEMA {
184 return Err(EconsimValidationError::UnsupportedSchema(
185 matrix.schema.clone(),
186 ));
187 }
188 if matrix.profile_id.trim().is_empty() {
189 return Err(EconsimValidationError::EmptyField("profileId"));
190 }
191 if matrix.cases.is_empty() {
192 return Err(EconsimValidationError::EmptyField("cases"));
193 }
194 validate_provenance(&matrix.harness_provenance)?;
195 let mut ids = BTreeSet::new();
196 let mut classes = BTreeSet::new();
197 for case in &matrix.cases {
198 validate_econsim_scenario_result(case)?;
199 if !ids.insert(case.scenario_id.as_str()) {
200 return Err(EconsimValidationError::DuplicateScenario(
201 case.scenario_id.clone(),
202 ));
203 }
204 classes.insert(case.scenario_class.as_str());
205 }
206 for class in ECONSIM_SCENARIO_CLASSES {
207 if !classes.contains(class) {
208 return Err(EconsimValidationError::MissingClass(class.to_owned()));
209 }
210 }
211 Ok(())
212}
213
214pub fn sign_econsim_qualification_matrix(
215 matrix: EconsimQualificationMatrix,
216 signer: &Keypair,
217) -> Result<SignedEconsimQualificationMatrix, EconsimValidationError> {
218 validate_econsim_qualification_matrix(&matrix)?;
219 if matrix.cases.iter().any(|case| {
220 case.outcome == EconsimOutcome::TargetMissing
221 || case.finding_severity >= Some(EconsimFindingSeverity::High)
222 }) {
223 return Err(EconsimValidationError::SigningBoundary(
224 "missing targets and unresolved high or critical findings are not signable",
225 ));
226 }
227 SignedExportEnvelope::sign(matrix, signer)
228 .map_err(|error| EconsimValidationError::Signing(error.to_string()))
229}
230
231fn validate_provenance(
232 provenance: &EconsimHarnessProvenance,
233) -> Result<(), EconsimValidationError> {
234 for (value, field) in [
235 (&provenance.git_commit, "harnessProvenance.gitCommit"),
236 (
237 &provenance.source_tree_state,
238 "harnessProvenance.sourceTreeState",
239 ),
240 (
241 &provenance.source_tree_digest,
242 "harnessProvenance.sourceTreeDigest",
243 ),
244 (
245 &provenance.executable_digest,
246 "harnessProvenance.executableDigest",
247 ),
248 (
249 &provenance.cargo_lock_digest,
250 "harnessProvenance.cargoLockDigest",
251 ),
252 (&provenance.target_triple, "harnessProvenance.targetTriple"),
253 (&provenance.rustc_version, "harnessProvenance.rustcVersion"),
254 (&provenance.cargo_version, "harnessProvenance.cargoVersion"),
255 (
256 &provenance.scenario_manifest_digest,
257 "harnessProvenance.scenarioManifestDigest",
258 ),
259 (
260 &provenance.corpus_manifest_digest,
261 "harnessProvenance.corpusManifestDigest",
262 ),
263 (&provenance.runner_key_id, "harnessProvenance.runnerKeyId"),
264 (
265 &provenance.qualification_scope,
266 "harnessProvenance.qualificationScope",
267 ),
268 ] {
269 if value.trim().is_empty() {
270 return Err(EconsimValidationError::EmptyField(field));
271 }
272 }
273 if provenance.command.is_empty() {
274 return Err(EconsimValidationError::EmptyField(
275 "harnessProvenance.command",
276 ));
277 }
278 Ok(())
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 fn held(class: &str) -> EconsimScenarioResult {
286 EconsimScenarioResult {
287 schema: ECONSIM_SCENARIO_RESULT_SCHEMA.to_owned(),
288 scenario_id: format!("{class}-seed-7"),
289 scenario_class: class.to_owned(),
290 seed: 7,
291 corpus_manifest_digest: "a".repeat(64),
292 requirement_ids: vec!["AE-TEST".to_owned()],
293 expected_disposition: EconsimDisposition::FailClosed,
294 observed_disposition: Some(EconsimDisposition::FailClosed),
295 target_status: EconsimTargetStatus::Bound,
296 outcome: EconsimOutcome::Held,
297 finding_severity: None,
298 assertion_scope: "the named production validator rejected the corpus".to_owned(),
299 explicit_limits: vec!["internal qualification only".to_owned()],
300 }
301 }
302
303 fn provenance() -> EconsimHarnessProvenance {
304 EconsimHarnessProvenance {
305 git_commit: "abc".to_owned(),
306 source_tree_state: "clean".to_owned(),
307 source_tree_digest: "b".repeat(64),
308 executable_digest: "c".repeat(64),
309 cargo_lock_digest: "d".repeat(64),
310 enabled_features: Vec::new(),
311 target_triple: "test-target".to_owned(),
312 rustc_version: "rustc test".to_owned(),
313 cargo_version: "cargo test".to_owned(),
314 command: vec!["chio-econsim-runner".to_owned()],
315 scenario_manifest_digest: "e".repeat(64),
316 corpus_manifest_digest: "a".repeat(64),
317 runner_key_id: "runner-test".to_owned(),
318 qualification_scope: "internal qualification only".to_owned(),
319 }
320 }
321
322 fn matrix() -> EconsimQualificationMatrix {
323 EconsimQualificationMatrix {
324 schema: ECONSIM_QUALIFICATION_MATRIX_SCHEMA.to_owned(),
325 profile_id: "econsim-v1".to_owned(),
326 harness_provenance: provenance(),
327 cases: ECONSIM_SCENARIO_CLASSES.into_iter().map(held).collect(),
328 }
329 }
330
331 #[test]
332 fn complete_held_matrix_signs_and_verifies() {
333 let signed = sign_econsim_qualification_matrix(matrix(), &Keypair::from_seed(&[7; 32]))
334 .expect("complete econsim matrix signs");
335 assert!(signed.verify_signature().expect("signature verifies"));
336 }
337
338 #[test]
339 fn high_finding_and_missing_target_do_not_cross_signing_boundary() {
340 for (outcome, target, observed) in [
341 (
342 EconsimOutcome::Finding,
343 EconsimTargetStatus::Bound,
344 Some(EconsimDisposition::Breach),
345 ),
346 (
347 EconsimOutcome::TargetMissing,
348 EconsimTargetStatus::Missing,
349 None,
350 ),
351 ] {
352 let mut candidate = matrix();
353 candidate.cases[0].outcome = outcome;
354 candidate.cases[0].target_status = target;
355 candidate.cases[0].observed_disposition = observed;
356 candidate.cases[0].finding_severity = Some(EconsimFindingSeverity::High);
357 assert!(matches!(
358 sign_econsim_qualification_matrix(candidate, &Keypair::from_seed(&[7; 32])),
359 Err(EconsimValidationError::SigningBoundary(_))
360 ));
361 }
362 }
363
364 #[test]
365 fn short_unknown_and_cross_field_invalid_matrices_reject() {
366 let mut short = matrix();
367 short.cases.pop();
368 assert!(matches!(
369 validate_econsim_qualification_matrix(&short),
370 Err(EconsimValidationError::MissingClass(_))
371 ));
372
373 let mut unknown = held("sybil-pricing-ring");
374 unknown.scenario_class = "unknown".to_owned();
375 assert!(matches!(
376 validate_econsim_scenario_result(&unknown),
377 Err(EconsimValidationError::UnknownClass(_))
378 ));
379
380 let mut invalid = held("sybil-pricing-ring");
381 invalid.finding_severity = Some(EconsimFindingSeverity::Low);
382 assert_eq!(
383 validate_econsim_scenario_result(&invalid),
384 Err(EconsimValidationError::InvalidResult(
385 "outcome, target, disposition, and severity disagree"
386 ))
387 );
388 }
389}