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.1";
12/// Exact-output default for the current read-only quote surface.
13pub const DEFAULT_SLIPPAGE_BPS: u16 = 0;
14
15/// Canonical v1 examples used to prove cross-language contract parity.
16///
17/// This module is excluded from ordinary production builds and exists only for
18/// crate verification and downstream SDK tests.
19#[cfg(any(test, feature = "fixtures"))]
20#[doc(hidden)]
21pub mod contract_fixtures {
22    pub const CAPABILITIES: &str = include_str!("../fixtures/v1/capabilities.json");
23    pub const EXECUTION_CHALLENGE: &str = include_str!("../fixtures/v1/execution-challenge.json");
24    pub const EXECUTION_PREPARE: &str = include_str!("../fixtures/v1/execution-prepare.json");
25    pub const EXECUTION_SUBMIT: &str = include_str!("../fixtures/v1/execution-submit.json");
26    pub const MARKETS: &str = include_str!("../fixtures/v1/markets.json");
27    pub const QUOTE: &str = include_str!("../fixtures/v1/quote.json");
28}
29
30#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
31#[serde(rename_all = "snake_case")]
32pub enum QuoteSide {
33    Buy,
34    Sell,
35}
36
37#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
38#[serde(deny_unknown_fields)]
39pub struct QuoteRequest {
40    pub market_id: String,
41    pub side: QuoteSide,
42    /// Atomic input amount encoded as a base-10 string. Public money values
43    /// never cross JSON as floating-point numbers.
44    pub amount_in_atoms: String,
45    /// Maximum execution tolerance. Use [`DEFAULT_SLIPPAGE_BPS`] for an exact
46    /// read-only quote.
47    pub slippage_bps: u16,
48}
49
50#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
51#[serde(deny_unknown_fields)]
52pub struct QuoteResponse {
53    pub schema_version: u16,
54    pub contract_version: String,
55    /// Opaque, short-lived handle. It identifies no execution source and
56    /// carries no readable Sonar plan material.
57    pub quote_id: String,
58    pub server_time_ms: u64,
59    pub expires_at_ms: u64,
60    pub market_id: String,
61    pub side: QuoteSide,
62    pub amount_in_atoms: String,
63    /// Requested input actually consumed by the quoted execution.
64    pub amount_in_consumed_atoms: String,
65    pub amount_out_atoms: String,
66    pub minimum_output_atoms: String,
67    /// Fees charged in the request's input asset. Sonar can charge fees on
68    /// either side, so a single unlabelled fee is unsafe.
69    pub input_fee_atoms: String,
70    /// Fees charged in the response's output asset.
71    pub output_fee_atoms: String,
72    /// Display-only decimal strings. SDKs may parse these for presentation but
73    /// must not use them for settlement or signing bounds.
74    pub reference_price: String,
75    pub price_impact_pct: String,
76    pub provider: String,
77}
78
79/// Ask Strata for a one-time payload authorizing preparation of an existing
80/// Sonar quote. The session key signs locally; no private signing material is
81/// accepted by this contract.
82#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
83#[serde(deny_unknown_fields)]
84pub struct ExecutionChallengeRequest {
85    pub quote_id: String,
86    pub owner_wallet: String,
87    pub session_public_key: String,
88    /// Vault-owned Market account sequence encoded as an unsigned decimal
89    /// string. It prevents a prepared internal fill from targeting stale state.
90    pub account_sequence: String,
91}
92
93#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
94#[serde(deny_unknown_fields)]
95pub struct ExecutionChallengeResponse {
96    pub schema_version: u16,
97    pub contract_version: String,
98    pub challenge_id: String,
99    pub quote_id: String,
100    pub market_id: String,
101    pub side: QuoteSide,
102    pub amount_in_atoms: String,
103    /// The sole customer-facing execution protection.
104    pub minimum_output_atoms: String,
105    /// Canonical bytes to sign locally with the declared session key.
106    pub authorization_payload_base64: String,
107    pub server_time_ms: u64,
108    pub expires_at_ms: u64,
109}
110
111#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
112#[serde(deny_unknown_fields)]
113pub struct ExecutionPrepareRequest {
114    pub challenge_id: String,
115    /// Base58 Ed25519 signature over `authorization_payload_base64`.
116    pub authorization_signature: String,
117}
118
119#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
120#[serde(deny_unknown_fields)]
121pub struct ExecutionPrepareResponse {
122    pub schema_version: u16,
123    pub contract_version: String,
124    pub execution_id: String,
125    pub quote_id: String,
126    pub market_id: String,
127    pub side: QuoteSide,
128    pub amount_in_atoms: String,
129    /// The same signed minimum returned by the challenge. Preparation may fail,
130    /// but it may never weaken this value.
131    pub minimum_output_atoms: String,
132    /// Partially signed Solana v0 transaction. The session signature slot is
133    /// deliberately empty and must be filled locally.
134    pub transaction_base64: String,
135    pub recent_blockhash: String,
136    pub last_valid_block_height: u64,
137    pub expires_at_ms: u64,
138}
139
140#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
141#[serde(deny_unknown_fields)]
142pub struct ExecutionSubmitRequest {
143    pub execution_id: String,
144    pub signed_transaction_base64: String,
145    /// Caller-generated opaque key. Repeating it may return the original
146    /// result, but can never create a second execution.
147    pub idempotency_key: String,
148}
149
150#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
151#[serde(deny_unknown_fields)]
152pub struct ExecutionSubmitResponse {
153    pub schema_version: u16,
154    pub contract_version: String,
155    pub execution_id: String,
156    pub signature: String,
157    pub status: ExecutionStatus,
158}
159
160#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
161#[serde(rename_all = "snake_case")]
162pub enum ExecutionStatus {
163    Submitted,
164}
165
166#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
167#[serde(deny_unknown_fields)]
168pub struct Market {
169    pub base: String,
170    pub quote: String,
171    pub market_pda: Option<String>,
172    pub label: String,
173    /// Whether the public Sonar quote operation is enabled for this market.
174    /// Liquidity remains live state and a quote can still be temporarily
175    /// unavailable.
176    pub ready: bool,
177    pub base_decimals: u8,
178    pub quote_decimals: u8,
179    /// Stable product-level operation for a Sonar quote. Its implementation
180    /// remains opaque.
181    pub quote_path: Option<String>,
182}
183
184#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
185#[serde(deny_unknown_fields)]
186pub struct MarketsResponse {
187    pub schema_version: u16,
188    pub contract_version: String,
189    pub markets: Vec<Market>,
190}
191
192impl MarketsResponse {
193    pub fn new(markets: Vec<Market>) -> Self {
194        Self {
195            schema_version: CONTRACT_MAJOR,
196            contract_version: CONTRACT_VERSION.to_owned(),
197            markets,
198        }
199    }
200}
201
202#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
203#[serde(deny_unknown_fields)]
204pub struct ErrorDetail {
205    pub code: String,
206    pub message: String,
207    pub retryable: bool,
208}
209
210#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
211#[serde(deny_unknown_fields)]
212pub struct ErrorResponse {
213    pub schema_version: u16,
214    pub contract_version: String,
215    pub error: ErrorDetail,
216}
217
218impl ErrorResponse {
219    pub fn new(code: impl Into<String>, message: impl Into<String>, retryable: bool) -> Self {
220        Self {
221            schema_version: CONTRACT_MAJOR,
222            contract_version: CONTRACT_VERSION.to_owned(),
223            error: ErrorDetail {
224                code: code.into(),
225                message: message.into(),
226                retryable,
227            },
228        }
229    }
230}
231
232#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
233#[serde(rename_all = "snake_case")]
234pub enum CapabilityStability {
235    Internal,
236    Beta,
237    Stable,
238}
239
240#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
241#[serde(rename_all = "snake_case")]
242pub enum CapabilityRisk {
243    Read,
244    Prepare,
245    Submit,
246    Destructive,
247}
248
249#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
250#[serde(rename_all = "snake_case")]
251pub enum McpExposure {
252    None,
253    Read,
254    Prepare,
255    Submit,
256}
257
258#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
259#[serde(deny_unknown_fields)]
260pub struct CapabilityDescriptor {
261    pub id: String,
262    pub introduced_in: String,
263    pub stability: CapabilityStability,
264    pub required_scope: String,
265    pub risk: CapabilityRisk,
266    pub default_enabled: bool,
267    pub public_sdk: bool,
268    pub mcp_exposure: McpExposure,
269}
270
271#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
272#[serde(deny_unknown_fields)]
273pub struct CapabilityCatalog {
274    pub schema_version: u16,
275    pub contract_version: String,
276    pub capabilities: Vec<CapabilityDescriptor>,
277}
278
279impl CapabilityCatalog {
280    pub fn foundation() -> Self {
281        use CapabilityRisk::{Prepare, Read, Submit};
282        use CapabilityStability::{Beta, Stable};
283        use McpExposure::{None as McpNone, Read as McpRead};
284
285        let capability = |id: &str,
286                          introduced_in: &str,
287                          stability,
288                          scope: &str,
289                          risk,
290                          default_enabled,
291                          public_sdk,
292                          mcp_exposure| {
293            CapabilityDescriptor {
294                id: id.to_owned(),
295                introduced_in: introduced_in.to_owned(),
296                stability,
297                required_scope: scope.to_owned(),
298                risk,
299                default_enabled,
300                public_sdk,
301                mcp_exposure,
302            }
303        };
304
305        Self {
306            schema_version: CONTRACT_MAJOR,
307            contract_version: CONTRACT_VERSION.to_owned(),
308            capabilities: vec![
309                capability(
310                    "markets.read",
311                    "1.0",
312                    Stable,
313                    "market:read",
314                    Read,
315                    true,
316                    true,
317                    McpRead,
318                ),
319                capability(
320                    "books.read",
321                    "1.0",
322                    Beta,
323                    "market:read",
324                    Read,
325                    false,
326                    false,
327                    McpNone,
328                ),
329                capability(
330                    "quotes.read",
331                    "1.0",
332                    Beta,
333                    "market:read",
334                    Read,
335                    true,
336                    true,
337                    McpRead,
338                ),
339                capability(
340                    "account.read",
341                    "1.0",
342                    Beta,
343                    "account:read",
344                    Read,
345                    false,
346                    false,
347                    McpNone,
348                ),
349                capability(
350                    "trade.prepare",
351                    "1.1",
352                    Beta,
353                    "trade:prepare",
354                    Prepare,
355                    false,
356                    true,
357                    McpNone,
358                ),
359                capability(
360                    "trade.submit",
361                    "1.1",
362                    Beta,
363                    "trade:submit",
364                    Submit,
365                    false,
366                    true,
367                    McpNone,
368                ),
369            ],
370        }
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn public_quote_field_set_is_sealed() {
380        let quote: QuoteResponse = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
381        let value = serde_json::to_value(quote).unwrap();
382        let object = value.as_object().unwrap();
383        let mut actual = object.keys().map(String::as_str).collect::<Vec<_>>();
384        actual.sort_unstable();
385        let mut expected = vec![
386            "amount_in_atoms",
387            "amount_in_consumed_atoms",
388            "amount_out_atoms",
389            "contract_version",
390            "expires_at_ms",
391            "input_fee_atoms",
392            "market_id",
393            "minimum_output_atoms",
394            "output_fee_atoms",
395            "price_impact_pct",
396            "provider",
397            "quote_id",
398            "reference_price",
399            "schema_version",
400            "server_time_ms",
401            "side",
402        ];
403        expected.sort_unstable();
404        assert_eq!(actual, expected, "public quote fields must remain sealed");
405        assert_eq!(object["amount_out_atoms"], "1990000");
406        assert_eq!(object["minimum_output_atoms"], "1980050");
407        assert_eq!(object["provider"], "Sonar");
408    }
409
410    #[test]
411    fn new_capabilities_are_safe_by_default() {
412        let catalog = CapabilityCatalog::foundation();
413        for capability in catalog.capabilities {
414            if capability.risk != CapabilityRisk::Read {
415                assert!(!capability.default_enabled);
416            }
417        }
418    }
419
420    #[test]
421    fn shared_v1_fixtures_decode_strictly() {
422        let quote: QuoteResponse = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
423        let markets: MarketsResponse = serde_json::from_str(contract_fixtures::MARKETS).unwrap();
424        let capabilities: CapabilityCatalog =
425            serde_json::from_str(contract_fixtures::CAPABILITIES).unwrap();
426
427        assert_eq!(quote.contract_version, CONTRACT_VERSION);
428        assert_eq!(markets.contract_version, CONTRACT_VERSION);
429        assert_eq!(capabilities, CapabilityCatalog::foundation());
430    }
431
432    #[test]
433    fn strict_contract_rejects_unreviewed_quote_fields() {
434        let mut value: serde_json::Value = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
435        value
436            .as_object_mut()
437            .unwrap()
438            .insert("unexpected_field".to_owned(), serde_json::json!("hidden"));
439
440        assert!(serde_json::from_value::<QuoteResponse>(value).is_err());
441    }
442
443    #[test]
444    fn execution_contract_exposes_only_minimum_output_protection() {
445        let challenge: ExecutionChallengeResponse =
446            serde_json::from_str(contract_fixtures::EXECUTION_CHALLENGE).unwrap();
447        let prepared: ExecutionPrepareResponse =
448            serde_json::from_str(contract_fixtures::EXECUTION_PREPARE).unwrap();
449        let submitted: ExecutionSubmitResponse =
450            serde_json::from_str(contract_fixtures::EXECUTION_SUBMIT).unwrap();
451
452        assert_eq!(
453            challenge.minimum_output_atoms,
454            prepared.minimum_output_atoms
455        );
456        assert_eq!(challenge.quote_id, prepared.quote_id);
457        assert_eq!(challenge.market_id, prepared.market_id);
458        assert_eq!(submitted.execution_id, prepared.execution_id);
459
460        for fixture in [
461            contract_fixtures::EXECUTION_CHALLENGE,
462            contract_fixtures::EXECUTION_PREPARE,
463            contract_fixtures::EXECUTION_SUBMIT,
464        ] {
465            let value: serde_json::Value = serde_json::from_str(fixture).unwrap();
466            let keys = value.as_object().unwrap().keys().collect::<Vec<_>>();
467            for forbidden in [
468                "route",
469                "venue",
470                "layer",
471                "plan",
472                "collar",
473                "limit_price",
474                "internal",
475                "l3",
476                "footprint",
477            ] {
478                assert!(
479                    keys.iter().all(|key| !key.contains(forbidden)),
480                    "execution contract exposed forbidden field containing {forbidden}"
481                );
482            }
483        }
484    }
485}