use chio_core_types::canonical::canonical_json_bytes;
use chio_core_types::crypto::{Keypair, PublicKey};
use chio_core_types::receipt::{ChioReceipt, SignedExportEnvelope};
use serde::{Deserialize, Serialize};
pub const REGULATORY_RECEIPT_EXPORT_SCHEMA: &str = "chio.regulatory.receipt-export.v1";
pub const MAX_REGULATORY_EXPORT_LIMIT: usize = 200;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RegulatoryReceiptExport {
pub schema: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before: Option<u64>,
pub matching_receipts: u64,
pub generated_at: u64,
pub receipts: Vec<ChioReceipt>,
}
pub type SignedRegulatoryReceiptExport = SignedExportEnvelope<RegulatoryReceiptExport>;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RegulatoryReceiptsQuery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub after: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub before: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
}
impl RegulatoryReceiptsQuery {
#[must_use]
pub fn limit_or_default(&self) -> usize {
self.limit
.unwrap_or(MAX_REGULATORY_EXPORT_LIMIT)
.clamp(1, MAX_REGULATORY_EXPORT_LIMIT)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegulatoryApiError {
BadRequest(String),
Unauthorized,
StoreUnavailable(String),
Signing(String),
}
impl RegulatoryApiError {
#[must_use]
pub fn status(&self) -> u16 {
match self {
Self::BadRequest(_) => 400,
Self::Unauthorized => 401,
Self::StoreUnavailable(_) => 503,
Self::Signing(_) => 500,
}
}
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::BadRequest(_) => "bad_request",
Self::Unauthorized => "unauthorized",
Self::StoreUnavailable(_) => "store_unavailable",
Self::Signing(_) => "signing_error",
}
}
#[must_use]
pub fn message(&self) -> String {
match self {
Self::BadRequest(reason) => reason.clone(),
Self::Unauthorized => "regulatory API access denied".to_string(),
Self::StoreUnavailable(reason) => reason.clone(),
Self::Signing(reason) => reason.clone(),
}
}
#[must_use]
pub fn body(&self) -> serde_json::Value {
serde_json::json!({
"error": self.code(),
"message": self.message(),
})
}
}
pub trait RegulatoryReceiptSource: Send + Sync {
fn query_receipts(
&self,
query: &RegulatoryReceiptsQuery,
) -> Result<RegulatoryReceiptQueryResult, RegulatoryApiError>;
}
#[derive(Debug, Clone, Default)]
pub struct RegulatoryReceiptQueryResult {
pub matching_receipts: u64,
pub receipts: Vec<ChioReceipt>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegulatorIdentity {
pub id: String,
}
pub fn handle_regulatory_receipts_signed(
source: &dyn RegulatoryReceiptSource,
identity: Option<&RegulatorIdentity>,
query: &RegulatoryReceiptsQuery,
keypair: &Keypair,
now: u64,
) -> Result<SignedRegulatoryReceiptExport, RegulatoryApiError> {
let _identity = identity.ok_or(RegulatoryApiError::Unauthorized)?;
if let (Some(after), Some(before)) = (query.after, query.before) {
if after > before {
return Err(RegulatoryApiError::BadRequest(
"after must be <= before".to_string(),
));
}
}
let raw = source.query_receipts(query)?;
let body = RegulatoryReceiptExport {
schema: REGULATORY_RECEIPT_EXPORT_SCHEMA.to_string(),
agent_id: query.agent.clone(),
after: query.after,
before: query.before,
matching_receipts: raw.matching_receipts,
generated_at: now,
receipts: raw.receipts,
};
sign_regulatory_export(body, keypair)
}
pub fn sign_regulatory_export(
body: RegulatoryReceiptExport,
keypair: &Keypair,
) -> Result<SignedRegulatoryReceiptExport, RegulatoryApiError> {
SignedExportEnvelope::sign(body, keypair)
.map_err(|e| RegulatoryApiError::Signing(e.to_string()))
}
pub fn verify_regulatory_export(
envelope: &SignedRegulatoryReceiptExport,
expected_signer: &PublicKey,
) -> Result<bool, RegulatoryApiError> {
if envelope.body.schema != REGULATORY_RECEIPT_EXPORT_SCHEMA {
return Err(RegulatoryApiError::BadRequest(format!(
"unexpected schema {:?}",
envelope.body.schema
)));
}
if &envelope.signer_key != expected_signer {
return Ok(false);
}
canonical_json_bytes(&envelope.body).map_err(|e| RegulatoryApiError::Signing(e.to_string()))?;
envelope
.verify_signature()
.map_err(|e| RegulatoryApiError::Signing(e.to_string()))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
struct FixedSource {
result: RegulatoryReceiptQueryResult,
}
impl RegulatoryReceiptSource for FixedSource {
fn query_receipts(
&self,
_query: &RegulatoryReceiptsQuery,
) -> Result<RegulatoryReceiptQueryResult, RegulatoryApiError> {
Ok(self.result.clone())
}
}
#[test]
fn signed_export_verifies_with_matching_keypair() {
let keypair = Keypair::generate();
let source = FixedSource {
result: RegulatoryReceiptQueryResult::default(),
};
let identity = RegulatorIdentity {
id: "regulator".to_string(),
};
let envelope = handle_regulatory_receipts_signed(
&source,
Some(&identity),
&RegulatoryReceiptsQuery::default(),
&keypair,
42,
)
.unwrap();
assert!(verify_regulatory_export(&envelope, &keypair.public_key()).unwrap());
}
#[test]
fn unauthorized_caller_is_rejected() {
let keypair = Keypair::generate();
let source = FixedSource {
result: RegulatoryReceiptQueryResult::default(),
};
let err = handle_regulatory_receipts_signed(
&source,
None,
&RegulatoryReceiptsQuery::default(),
&keypair,
0,
)
.expect_err("unauthorized must reject");
assert_eq!(err.status(), 401);
}
#[test]
fn stale_time_window_is_rejected() {
let keypair = Keypair::generate();
let source = FixedSource {
result: RegulatoryReceiptQueryResult::default(),
};
let identity = RegulatorIdentity {
id: "regulator".to_string(),
};
let err = handle_regulatory_receipts_signed(
&source,
Some(&identity),
&RegulatoryReceiptsQuery {
after: Some(100),
before: Some(50),
..Default::default()
},
&keypair,
0,
)
.expect_err("after>before must reject");
assert_eq!(err.status(), 400);
}
}