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