1use chio_core_types::canonical::canonical_json_bytes;
16use chio_core_types::crypto::{Keypair, PublicKey};
17use chio_core_types::receipt::{body::ChioReceipt, lineage::SignedExportEnvelope};
18use chio_kernel::ReceiptReadContext;
19use serde::{Deserialize, Serialize};
20
21pub const REGULATORY_RECEIPT_EXPORT_SCHEMA: &str = "chio.regulatory.receipt-export.v1";
23
24pub const MAX_REGULATORY_EXPORT_LIMIT: usize = 200;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct RegulatoryReceiptExport {
33 pub schema: String,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub agent_id: Option<String>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub after: Option<u64>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub before: Option<u64>,
44 pub matching_receipts: u64,
46 pub generated_at: u64,
48 pub receipts: Vec<ChioReceipt>,
50}
51
52pub type SignedRegulatoryReceiptExport = SignedExportEnvelope<RegulatoryReceiptExport>;
54
55#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
57#[serde(rename_all = "camelCase")]
58pub struct RegulatoryReceiptsQuery {
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub agent: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub after: Option<u64>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub before: Option<u64>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub limit: Option<usize>,
72}
73
74impl RegulatoryReceiptsQuery {
75 #[must_use]
76 pub fn limit_or_default(&self) -> usize {
77 self.limit
78 .unwrap_or(MAX_REGULATORY_EXPORT_LIMIT)
79 .clamp(1, MAX_REGULATORY_EXPORT_LIMIT)
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum RegulatoryApiError {
86 BadRequest(String),
88 Unauthorized,
90 StoreUnavailable(String),
92 Signing(String),
94}
95
96impl RegulatoryApiError {
97 #[must_use]
99 pub fn status(&self) -> u16 {
100 match self {
101 Self::BadRequest(_) => 400,
102 Self::Unauthorized => 401,
103 Self::StoreUnavailable(_) => 503,
104 Self::Signing(_) => 500,
105 }
106 }
107
108 #[must_use]
110 pub fn code(&self) -> &'static str {
111 match self {
112 Self::BadRequest(_) => "bad_request",
113 Self::Unauthorized => "unauthorized",
114 Self::StoreUnavailable(_) => "store_unavailable",
115 Self::Signing(_) => "signing_error",
116 }
117 }
118
119 #[must_use]
121 pub fn message(&self) -> String {
122 match self {
123 Self::BadRequest(reason) => reason.clone(),
124 Self::Unauthorized => "regulatory API access denied".to_string(),
125 Self::StoreUnavailable(reason) => reason.clone(),
126 Self::Signing(reason) => reason.clone(),
127 }
128 }
129
130 #[must_use]
132 pub fn body(&self) -> serde_json::Value {
133 serde_json::json!({
134 "error": self.code(),
135 "message": self.message(),
136 })
137 }
138}
139
140pub trait RegulatoryReceiptSource: Send + Sync {
148 fn query_receipts(
152 &self,
153 query: &RegulatoryReceiptsQuery,
154 read_context: &ReceiptReadContext,
155 ) -> Result<RegulatoryReceiptQueryResult, RegulatoryApiError>;
156}
157
158#[derive(Debug, Clone, Default)]
160pub struct RegulatoryReceiptQueryResult {
161 pub matching_receipts: u64,
163 pub receipts: Vec<ChioReceipt>,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct RegulatorIdentity {
175 pub id: String,
178}
179
180pub fn handle_regulatory_receipts_signed(
197 source: &dyn RegulatoryReceiptSource,
198 identity: Option<&RegulatorIdentity>,
199 query: &RegulatoryReceiptsQuery,
200 keypair: &Keypair,
201 now: u64,
202) -> Result<SignedRegulatoryReceiptExport, RegulatoryApiError> {
203 let _identity = identity.ok_or(RegulatoryApiError::Unauthorized)?;
204
205 if let (Some(after), Some(before)) = (query.after, query.before) {
206 if after > before {
207 return Err(RegulatoryApiError::BadRequest(
208 "after must be <= before".to_string(),
209 ));
210 }
211 }
212
213 let read_context = ReceiptReadContext::admin_service();
214 let raw = source.query_receipts(query, &read_context)?;
215
216 let body = RegulatoryReceiptExport {
217 schema: REGULATORY_RECEIPT_EXPORT_SCHEMA.to_string(),
218 agent_id: query.agent.clone(),
219 after: query.after,
220 before: query.before,
221 matching_receipts: raw.matching_receipts,
222 generated_at: now,
223 receipts: raw.receipts,
224 };
225
226 sign_regulatory_export(body, keypair)
227}
228
229pub fn sign_regulatory_export(
234 body: RegulatoryReceiptExport,
235 keypair: &Keypair,
236) -> Result<SignedRegulatoryReceiptExport, RegulatoryApiError> {
237 SignedExportEnvelope::sign(body, keypair)
238 .map_err(|e| RegulatoryApiError::Signing(e.to_string()))
239}
240
241pub fn verify_regulatory_export(
246 envelope: &SignedRegulatoryReceiptExport,
247 expected_signer: &PublicKey,
248) -> Result<bool, RegulatoryApiError> {
249 if envelope.body.schema != REGULATORY_RECEIPT_EXPORT_SCHEMA {
250 return Err(RegulatoryApiError::BadRequest(format!(
251 "unexpected schema {:?}",
252 envelope.body.schema
253 )));
254 }
255 if &envelope.signer_key != expected_signer {
256 return Ok(false);
257 }
258 canonical_json_bytes(&envelope.body).map_err(|e| RegulatoryApiError::Signing(e.to_string()))?;
262 envelope
263 .verify_signature()
264 .map_err(|e| RegulatoryApiError::Signing(e.to_string()))
265}
266
267#[cfg(test)]
268#[allow(clippy::unwrap_used, clippy::expect_used)]
269mod tests {
270 use super::*;
271
272 struct FixedSource {
273 result: RegulatoryReceiptQueryResult,
274 }
275
276 impl RegulatoryReceiptSource for FixedSource {
277 fn query_receipts(
278 &self,
279 _query: &RegulatoryReceiptsQuery,
280 _read_context: &ReceiptReadContext,
281 ) -> Result<RegulatoryReceiptQueryResult, RegulatoryApiError> {
282 Ok(self.result.clone())
283 }
284 }
285
286 #[test]
287 fn signed_export_verifies_with_matching_keypair() {
288 let keypair = Keypair::generate();
289 let source = FixedSource {
290 result: RegulatoryReceiptQueryResult::default(),
291 };
292 let identity = RegulatorIdentity {
293 id: "regulator".to_string(),
294 };
295 let envelope = handle_regulatory_receipts_signed(
296 &source,
297 Some(&identity),
298 &RegulatoryReceiptsQuery::default(),
299 &keypair,
300 42,
301 )
302 .unwrap();
303
304 assert!(verify_regulatory_export(&envelope, &keypair.public_key()).unwrap());
305 }
306
307 #[test]
308 fn unauthorized_caller_is_rejected() {
309 let keypair = Keypair::generate();
310 let source = FixedSource {
311 result: RegulatoryReceiptQueryResult::default(),
312 };
313 let err = handle_regulatory_receipts_signed(
314 &source,
315 None,
316 &RegulatoryReceiptsQuery::default(),
317 &keypair,
318 0,
319 )
320 .expect_err("unauthorized must reject");
321 assert_eq!(err.status(), 401);
322 }
323
324 #[test]
325 fn stale_time_window_is_rejected() {
326 let keypair = Keypair::generate();
327 let source = FixedSource {
328 result: RegulatoryReceiptQueryResult::default(),
329 };
330 let identity = RegulatorIdentity {
331 id: "regulator".to_string(),
332 };
333 let err = handle_regulatory_receipts_signed(
334 &source,
335 Some(&identity),
336 &RegulatoryReceiptsQuery {
337 after: Some(100),
338 before: Some(50),
339 ..Default::default()
340 },
341 &keypair,
342 0,
343 )
344 .expect_err("after>before must reject");
345 assert_eq!(err.status(), 400);
346 }
347}