Skip to main content

strata_public_contract/
lib.rs

1//! Strata's public product contract.
2//!
3//! This crate is intentionally isolated from Sonar implementation types. A
4//! server must explicitly convert its internal result into these DTOs, making
5//! accidental disclosure a compile-time-visible change instead of a serde
6//! side-effect.
7
8use serde::{Deserialize, Serialize};
9
10pub const CONTRACT_MAJOR: u16 = 1;
11pub const CONTRACT_VERSION: &str = "1.0";
12
13/// Canonical v1 examples used to prove cross-language contract parity.
14///
15/// This module is excluded from ordinary production builds and exists only for
16/// crate verification and downstream SDK tests.
17#[cfg(any(test, feature = "fixtures"))]
18#[doc(hidden)]
19pub mod contract_fixtures {
20    pub const CAPABILITIES: &str = include_str!("../fixtures/v1/capabilities.json");
21    pub const MARKETS: &str = include_str!("../fixtures/v1/markets.json");
22    pub const QUOTE: &str = include_str!("../fixtures/v1/quote.json");
23}
24
25#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
26#[serde(rename_all = "snake_case")]
27pub enum QuoteSide {
28    Buy,
29    Sell,
30}
31
32#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
33#[serde(deny_unknown_fields)]
34pub struct QuoteRequest {
35    pub market_id: String,
36    pub side: QuoteSide,
37    /// Atomic input amount encoded as a base-10 string. Public money values
38    /// never cross JSON as floating-point numbers.
39    pub amount_in_atoms: String,
40    pub slippage_bps: u16,
41}
42
43#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[serde(deny_unknown_fields)]
45pub struct QuoteResponse {
46    pub schema_version: u16,
47    pub contract_version: String,
48    /// Opaque, short-lived handle. It identifies no execution source and
49    /// carries no readable Sonar plan material.
50    pub quote_id: String,
51    pub server_time_ms: u64,
52    pub expires_at_ms: u64,
53    pub market_id: String,
54    pub side: QuoteSide,
55    pub amount_in_atoms: String,
56    /// Requested input actually consumed by the quoted execution.
57    pub amount_in_consumed_atoms: String,
58    pub amount_out_atoms: String,
59    pub minimum_output_atoms: String,
60    /// Fees charged in the request's input asset. Sonar can charge fees on
61    /// either side, so a single unlabelled fee is unsafe.
62    pub input_fee_atoms: String,
63    /// Fees charged in the response's output asset.
64    pub output_fee_atoms: String,
65    /// Display-only decimal strings. SDKs may parse these for presentation but
66    /// must not use them for settlement or signing bounds.
67    pub reference_price: String,
68    pub price_impact_pct: String,
69    pub provider: String,
70}
71
72#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
73#[serde(deny_unknown_fields)]
74pub struct Market {
75    pub base: String,
76    pub quote: String,
77    pub market_pda: Option<String>,
78    pub label: String,
79    /// Whether the public Sonar quote operation is enabled for this market.
80    /// Liquidity remains live state and a quote can still be temporarily
81    /// unavailable.
82    pub ready: bool,
83    pub base_decimals: u8,
84    pub quote_decimals: u8,
85    /// Stable product-level operation for a Sonar quote. Its implementation
86    /// remains opaque.
87    pub quote_path: Option<String>,
88}
89
90#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
91#[serde(deny_unknown_fields)]
92pub struct MarketsResponse {
93    pub schema_version: u16,
94    pub contract_version: String,
95    pub markets: Vec<Market>,
96}
97
98impl MarketsResponse {
99    pub fn new(markets: Vec<Market>) -> Self {
100        Self {
101            schema_version: CONTRACT_MAJOR,
102            contract_version: CONTRACT_VERSION.to_owned(),
103            markets,
104        }
105    }
106}
107
108#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
109#[serde(deny_unknown_fields)]
110pub struct ErrorDetail {
111    pub code: String,
112    pub message: String,
113    pub retryable: bool,
114}
115
116#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
117#[serde(deny_unknown_fields)]
118pub struct ErrorResponse {
119    pub schema_version: u16,
120    pub contract_version: String,
121    pub error: ErrorDetail,
122}
123
124impl ErrorResponse {
125    pub fn new(code: impl Into<String>, message: impl Into<String>, retryable: bool) -> Self {
126        Self {
127            schema_version: CONTRACT_MAJOR,
128            contract_version: CONTRACT_VERSION.to_owned(),
129            error: ErrorDetail {
130                code: code.into(),
131                message: message.into(),
132                retryable,
133            },
134        }
135    }
136}
137
138#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
139#[serde(rename_all = "snake_case")]
140pub enum CapabilityStability {
141    Internal,
142    Beta,
143    Stable,
144}
145
146#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
147#[serde(rename_all = "snake_case")]
148pub enum CapabilityRisk {
149    Read,
150    Prepare,
151    Submit,
152    Destructive,
153}
154
155#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
156#[serde(rename_all = "snake_case")]
157pub enum McpExposure {
158    None,
159    Read,
160    Prepare,
161    Submit,
162}
163
164#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
165#[serde(deny_unknown_fields)]
166pub struct CapabilityDescriptor {
167    pub id: String,
168    pub introduced_in: String,
169    pub stability: CapabilityStability,
170    pub required_scope: String,
171    pub risk: CapabilityRisk,
172    pub default_enabled: bool,
173    pub public_sdk: bool,
174    pub mcp_exposure: McpExposure,
175}
176
177#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
178#[serde(deny_unknown_fields)]
179pub struct CapabilityCatalog {
180    pub schema_version: u16,
181    pub contract_version: String,
182    pub capabilities: Vec<CapabilityDescriptor>,
183}
184
185impl CapabilityCatalog {
186    pub fn foundation() -> Self {
187        use CapabilityRisk::{Prepare, Read, Submit};
188        use CapabilityStability::{Beta, Stable};
189        use McpExposure::{None as McpNone, Read as McpRead};
190
191        let capability =
192            |id: &str, stability, scope: &str, risk, default_enabled, public_sdk, mcp_exposure| {
193                CapabilityDescriptor {
194                    id: id.to_owned(),
195                    introduced_in: CONTRACT_VERSION.to_owned(),
196                    stability,
197                    required_scope: scope.to_owned(),
198                    risk,
199                    default_enabled,
200                    public_sdk,
201                    mcp_exposure,
202                }
203            };
204
205        Self {
206            schema_version: CONTRACT_MAJOR,
207            contract_version: CONTRACT_VERSION.to_owned(),
208            capabilities: vec![
209                capability(
210                    "markets.read",
211                    Stable,
212                    "market:read",
213                    Read,
214                    true,
215                    true,
216                    McpRead,
217                ),
218                capability(
219                    "books.read",
220                    Beta,
221                    "market:read",
222                    Read,
223                    false,
224                    false,
225                    McpNone,
226                ),
227                capability(
228                    "quotes.read",
229                    Beta,
230                    "market:read",
231                    Read,
232                    true,
233                    true,
234                    McpRead,
235                ),
236                capability(
237                    "account.read",
238                    Beta,
239                    "account:read",
240                    Read,
241                    false,
242                    false,
243                    McpNone,
244                ),
245                capability(
246                    "trade.prepare",
247                    Beta,
248                    "trade:prepare",
249                    Prepare,
250                    false,
251                    false,
252                    McpNone,
253                ),
254                capability(
255                    "trade.submit",
256                    Beta,
257                    "trade:submit",
258                    Submit,
259                    false,
260                    false,
261                    McpNone,
262                ),
263            ],
264        }
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn public_quote_field_set_is_sealed() {
274        let quote: QuoteResponse = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
275        let value = serde_json::to_value(quote).unwrap();
276        let object = value.as_object().unwrap();
277        let mut actual = object.keys().map(String::as_str).collect::<Vec<_>>();
278        actual.sort_unstable();
279        let mut expected = vec![
280            "amount_in_atoms",
281            "amount_in_consumed_atoms",
282            "amount_out_atoms",
283            "contract_version",
284            "expires_at_ms",
285            "input_fee_atoms",
286            "market_id",
287            "minimum_output_atoms",
288            "output_fee_atoms",
289            "price_impact_pct",
290            "provider",
291            "quote_id",
292            "reference_price",
293            "schema_version",
294            "server_time_ms",
295            "side",
296        ];
297        expected.sort_unstable();
298        assert_eq!(actual, expected, "public quote fields must remain sealed");
299        assert_eq!(object["amount_out_atoms"], "1990000");
300        assert_eq!(object["minimum_output_atoms"], "1980050");
301        assert_eq!(object["provider"], "Sonar");
302    }
303
304    #[test]
305    fn new_capabilities_are_safe_by_default() {
306        let catalog = CapabilityCatalog::foundation();
307        for capability in catalog.capabilities {
308            if capability.risk != CapabilityRisk::Read {
309                assert!(!capability.default_enabled);
310            }
311        }
312    }
313
314    #[test]
315    fn shared_v1_fixtures_decode_strictly() {
316        let quote: QuoteResponse = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
317        let markets: MarketsResponse = serde_json::from_str(contract_fixtures::MARKETS).unwrap();
318        let capabilities: CapabilityCatalog =
319            serde_json::from_str(contract_fixtures::CAPABILITIES).unwrap();
320
321        assert_eq!(quote.contract_version, CONTRACT_VERSION);
322        assert_eq!(markets.contract_version, CONTRACT_VERSION);
323        assert_eq!(capabilities, CapabilityCatalog::foundation());
324    }
325
326    #[test]
327    fn strict_contract_rejects_unreviewed_quote_fields() {
328        let mut value: serde_json::Value = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
329        value
330            .as_object_mut()
331            .unwrap()
332            .insert("unexpected_field".to_owned(), serde_json::json!("hidden"));
333
334        assert!(serde_json::from_value::<QuoteResponse>(value).is_err());
335    }
336}