Skip to main content

chio_fiscal/
fee_schedule.rs

1use chio_core_types::canonical_json_bytes;
2use chio_core_types::capability::scope::MonetaryAmount;
3use chio_core_types::crypto::sha256_hex;
4use chio_core_types::receipt::lineage::SignedExportEnvelope;
5use chio_listing::{normalize_namespace, GenericListingActorKind, GenericTrustAdmissionClass};
6use serde::{Deserialize, Serialize};
7
8pub const OPEN_MARKET_FEE_SCHEDULE_ARTIFACT_SCHEMA: &str = "chio.registry.market-fee-schedule.v1";
9
10#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
11#[serde(rename_all = "snake_case")]
12pub enum OpenMarketBondClass {
13    Publication,
14    Listing,
15    Dispute,
16}
17
18#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum OpenMarketCollateralReferenceKind {
21    CreditBond,
22    ExternalReference,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[serde(rename_all = "camelCase", deny_unknown_fields)]
27pub struct OpenMarketEconomicsScope {
28    pub namespace: String,
29    #[serde(default, skip_serializing_if = "Vec::is_empty")]
30    pub allowed_listing_operator_ids: Vec<String>,
31    #[serde(default, skip_serializing_if = "Vec::is_empty")]
32    pub allowed_actor_kinds: Vec<GenericListingActorKind>,
33    #[serde(default, skip_serializing_if = "Vec::is_empty")]
34    pub allowed_admission_classes: Vec<GenericTrustAdmissionClass>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub policy_reference: Option<String>,
37}
38
39impl OpenMarketEconomicsScope {
40    pub fn validate(&self) -> Result<(), String> {
41        validate_non_empty(&self.namespace, "scope.namespace")?;
42        for (index, operator_id) in self.allowed_listing_operator_ids.iter().enumerate() {
43            validate_non_empty(
44                operator_id,
45                &format!("scope.allowed_listing_operator_ids[{index}]"),
46            )?;
47        }
48        Ok(())
49    }
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53#[serde(rename_all = "camelCase", deny_unknown_fields)]
54pub struct OpenMarketBondRequirement {
55    pub bond_class: OpenMarketBondClass,
56    pub required_amount: MonetaryAmount,
57    pub collateral_reference_kind: OpenMarketCollateralReferenceKind,
58    pub slashable: bool,
59}
60
61impl OpenMarketBondRequirement {
62    pub fn validate(&self, field: &str) -> Result<(), String> {
63        validate_monetary_amount(&self.required_amount, &format!("{field}.required_amount"))
64    }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68#[serde(rename_all = "camelCase", deny_unknown_fields)]
69pub struct OpenMarketFeeScheduleArtifact {
70    pub schema: String,
71    pub fee_schedule_id: String,
72    pub namespace: String,
73    pub governing_operator_id: String,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub governing_operator_name: Option<String>,
76    pub scope: OpenMarketEconomicsScope,
77    pub publication_fee: MonetaryAmount,
78    pub dispute_fee: MonetaryAmount,
79    pub market_participation_fee: MonetaryAmount,
80    pub bond_requirements: Vec<OpenMarketBondRequirement>,
81    pub issued_at: u64,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub expires_at: Option<u64>,
84    pub issued_by: String,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub note: Option<String>,
87}
88
89impl OpenMarketFeeScheduleArtifact {
90    pub fn validate(&self) -> Result<(), String> {
91        if self.schema != OPEN_MARKET_FEE_SCHEDULE_ARTIFACT_SCHEMA {
92            return Err(format!(
93                "unsupported open-market fee schedule schema: {}",
94                self.schema
95            ));
96        }
97        validate_non_empty(&self.fee_schedule_id, "fee_schedule_id")?;
98        validate_non_empty(&self.namespace, "namespace")?;
99        validate_non_empty(&self.governing_operator_id, "governing_operator_id")?;
100        validate_non_empty(&self.issued_by, "issued_by")?;
101        self.scope.validate()?;
102        if normalize_namespace(&self.namespace) != normalize_namespace(&self.scope.namespace) {
103            return Err("fee schedule namespace must match scope namespace".to_string());
104        }
105        validate_monetary_amount(&self.publication_fee, "publication_fee")?;
106        validate_monetary_amount(&self.dispute_fee, "dispute_fee")?;
107        validate_monetary_amount(&self.market_participation_fee, "market_participation_fee")?;
108        if self.bond_requirements.is_empty() {
109            return Err("bond_requirements must not be empty".to_string());
110        }
111        for (index, requirement) in self.bond_requirements.iter().enumerate() {
112            requirement.validate(&format!("bond_requirements[{index}]"))?;
113        }
114        if let Some(expires_at) = self.expires_at {
115            if expires_at <= self.issued_at {
116                return Err("expires_at must be greater than issued_at".to_string());
117            }
118        }
119        Ok(())
120    }
121}
122
123pub type SignedOpenMarketFeeSchedule = SignedExportEnvelope<OpenMarketFeeScheduleArtifact>;
124
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126#[serde(rename_all = "camelCase", deny_unknown_fields)]
127pub struct OpenMarketFeeScheduleIssueRequest {
128    pub scope: OpenMarketEconomicsScope,
129    pub publication_fee: MonetaryAmount,
130    pub dispute_fee: MonetaryAmount,
131    pub market_participation_fee: MonetaryAmount,
132    pub bond_requirements: Vec<OpenMarketBondRequirement>,
133    pub issued_by: String,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub issued_at: Option<u64>,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub expires_at: Option<u64>,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub note: Option<String>,
140}
141
142impl OpenMarketFeeScheduleIssueRequest {
143    pub fn validate(&self) -> Result<(), String> {
144        self.scope.validate()?;
145        validate_non_empty(&self.issued_by, "issued_by")?;
146        validate_monetary_amount(&self.publication_fee, "publication_fee")?;
147        validate_monetary_amount(&self.dispute_fee, "dispute_fee")?;
148        validate_monetary_amount(&self.market_participation_fee, "market_participation_fee")?;
149        if self.bond_requirements.is_empty() {
150            return Err("bond_requirements must not be empty".to_string());
151        }
152        for (index, requirement) in self.bond_requirements.iter().enumerate() {
153            requirement.validate(&format!("bond_requirements[{index}]"))?;
154        }
155        Ok(())
156    }
157}
158
159pub fn build_open_market_fee_schedule_artifact(
160    local_operator_id: &str,
161    local_operator_name: Option<String>,
162    request: &OpenMarketFeeScheduleIssueRequest,
163    issued_at: u64,
164) -> Result<OpenMarketFeeScheduleArtifact, String> {
165    request.validate()?;
166    validate_non_empty(local_operator_id, "local_operator_id")?;
167    let issued_at = request.issued_at.unwrap_or(issued_at);
168    let fee_schedule_id = format!(
169        "market-fee-schedule-{}",
170        sha256_hex(
171            &canonical_json_bytes(&(
172                local_operator_id,
173                normalize_namespace(&request.scope.namespace),
174                &request.publication_fee,
175                &request.dispute_fee,
176                &request.market_participation_fee,
177                &request.bond_requirements,
178                issued_at,
179            ))
180            .map_err(|error| error.to_string())?
181        )
182    );
183    let artifact = OpenMarketFeeScheduleArtifact {
184        schema: OPEN_MARKET_FEE_SCHEDULE_ARTIFACT_SCHEMA.to_string(),
185        fee_schedule_id,
186        namespace: request.scope.namespace.clone(),
187        governing_operator_id: local_operator_id.to_string(),
188        governing_operator_name: local_operator_name,
189        scope: request.scope.clone(),
190        publication_fee: request.publication_fee.clone(),
191        dispute_fee: request.dispute_fee.clone(),
192        market_participation_fee: request.market_participation_fee.clone(),
193        bond_requirements: request.bond_requirements.clone(),
194        issued_at,
195        expires_at: request.expires_at,
196        issued_by: request.issued_by.clone(),
197        note: request.note.clone(),
198    };
199    artifact.validate()?;
200    Ok(artifact)
201}
202
203fn validate_monetary_amount(value: &MonetaryAmount, field: &str) -> Result<(), String> {
204    if value.units == 0 {
205        return Err(format!("{field}.units must be greater than zero"));
206    }
207    validate_non_empty(&value.currency, &format!("{field}.currency"))
208}
209
210fn validate_non_empty(value: &str, field: &str) -> Result<(), String> {
211    if value.trim().is_empty() {
212        Err(format!("{field} must not be empty"))
213    } else {
214        Ok(())
215    }
216}