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 mod platform;
11
12pub const CONTRACT_MAJOR: u16 = 1;
13pub const CONTRACT_VERSION: &str = "1.1";
14/// Exact-output default for the current read-only quote surface.
15pub const DEFAULT_SLIPPAGE_BPS: u16 = 0;
16
17/// Canonical v1 examples used to prove cross-language contract parity.
18///
19/// This module is excluded from ordinary production builds and exists only for
20/// crate verification and downstream SDK tests.
21#[cfg(any(test, feature = "fixtures"))]
22#[doc(hidden)]
23pub mod contract_fixtures {
24    pub const ACTION_GRAPH: &str = include_str!("../fixtures/v1/action-graph.json");
25    pub const CAPABILITIES: &str = include_str!("../fixtures/v1/capabilities.json");
26    pub const EXECUTION_CHALLENGE: &str = include_str!("../fixtures/v1/execution-challenge.json");
27    pub const EXECUTION_PREPARE: &str = include_str!("../fixtures/v1/execution-prepare.json");
28    pub const EXECUTION_SUBMIT: &str = include_str!("../fixtures/v1/execution-submit.json");
29    pub const MARKETS: &str = include_str!("../fixtures/v1/markets.json");
30    pub const QUOTE: &str = include_str!("../fixtures/v1/quote.json");
31}
32
33pub const ACTION_GRAPH_VERSION: &str = "1.0";
34
35#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
36#[serde(rename_all = "snake_case")]
37pub enum ActionNodeKind {
38    Discovery,
39    Read,
40    Prepare,
41    ExternalSignature,
42    Submit,
43    Receipt,
44}
45
46#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
47#[serde(deny_unknown_fields)]
48pub struct ActionAuthorityModel {
49    /// Permission and signer policy are configured by the external agent owner.
50    pub permission_source: String,
51    /// Private signing material stays in the owner's agent or wallet runtime.
52    pub signing_location: String,
53    /// Strata accepts public keys and signatures, never private key material.
54    pub accepts_private_keys: bool,
55}
56
57#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
58#[serde(deny_unknown_fields)]
59pub struct ActionOperation {
60    pub method: String,
61    pub path: String,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub mcp_tool: Option<String>,
64}
65
66#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
67#[serde(deny_unknown_fields)]
68pub struct ActionNode {
69    pub id: String,
70    pub kind: ActionNodeKind,
71    pub summary: String,
72    pub required_capabilities: Vec<String>,
73    /// Computed from the live capability catalog for callable Strata nodes.
74    pub available: bool,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub operation: Option<ActionOperation>,
77}
78
79#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
80#[serde(deny_unknown_fields)]
81pub struct ActionEdge {
82    pub from: String,
83    pub to: String,
84    pub condition: String,
85}
86
87#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
88#[serde(deny_unknown_fields)]
89pub struct ActionGraph {
90    pub schema_version: u16,
91    pub graph_version: String,
92    pub contract_version: String,
93    pub entry_node: String,
94    pub authority: ActionAuthorityModel,
95    pub nodes: Vec<ActionNode>,
96    pub edges: Vec<ActionEdge>,
97}
98
99#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
100#[serde(rename_all = "snake_case")]
101pub enum QuoteSide {
102    Buy,
103    Sell,
104}
105
106#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
107#[serde(deny_unknown_fields)]
108pub struct QuoteRequest {
109    pub market_id: String,
110    pub side: QuoteSide,
111    /// Atomic input amount encoded as a base-10 string. Public money values
112    /// never cross JSON as floating-point numbers.
113    pub amount_in_atoms: String,
114    /// Maximum execution tolerance. Use [`DEFAULT_SLIPPAGE_BPS`] for an exact
115    /// read-only quote.
116    pub slippage_bps: u16,
117}
118
119#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
120#[serde(deny_unknown_fields)]
121pub struct QuoteResponse {
122    pub schema_version: u16,
123    pub contract_version: String,
124    /// Opaque, short-lived handle. It identifies no execution source and
125    /// carries no readable Sonar plan material.
126    pub quote_id: String,
127    pub server_time_ms: u64,
128    pub expires_at_ms: u64,
129    pub market_id: String,
130    pub side: QuoteSide,
131    pub amount_in_atoms: String,
132    /// Requested input actually consumed by the quoted execution.
133    pub amount_in_consumed_atoms: String,
134    pub amount_out_atoms: String,
135    pub minimum_output_atoms: String,
136    /// Fees charged in the request's input asset. Sonar can charge fees on
137    /// either side, so a single unlabelled fee is unsafe.
138    pub input_fee_atoms: String,
139    /// Fees charged in the response's output asset.
140    pub output_fee_atoms: String,
141    /// Display-only decimal strings. SDKs may parse these for presentation but
142    /// must not use them for settlement or signing bounds.
143    pub reference_price: String,
144    pub price_impact_pct: String,
145    pub provider: String,
146}
147
148/// Ask Strata for a one-time payload authorizing preparation of an existing
149/// Sonar quote. The session key signs locally; no private signing material is
150/// accepted by this contract.
151#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
152#[serde(deny_unknown_fields)]
153pub struct ExecutionChallengeRequest {
154    pub quote_id: String,
155    pub owner_wallet: String,
156    pub session_public_key: String,
157    /// Vault-owned Market account sequence encoded as an unsigned decimal
158    /// string. It prevents a prepared internal fill from targeting stale state.
159    pub account_sequence: String,
160}
161
162#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
163#[serde(deny_unknown_fields)]
164pub struct ExecutionChallengeResponse {
165    pub schema_version: u16,
166    pub contract_version: String,
167    pub challenge_id: String,
168    pub quote_id: String,
169    pub market_id: String,
170    pub side: QuoteSide,
171    pub amount_in_atoms: String,
172    /// The sole customer-facing execution protection.
173    pub minimum_output_atoms: String,
174    /// Canonical bytes to sign locally with the declared session key.
175    pub authorization_payload_base64: String,
176    pub server_time_ms: u64,
177    pub expires_at_ms: u64,
178}
179
180#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
181#[serde(deny_unknown_fields)]
182pub struct ExecutionPrepareRequest {
183    pub challenge_id: String,
184    /// Base58 Ed25519 signature over `authorization_payload_base64`.
185    pub authorization_signature: String,
186}
187
188#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
189#[serde(deny_unknown_fields)]
190pub struct ExecutionPrepareResponse {
191    pub schema_version: u16,
192    pub contract_version: String,
193    pub execution_id: String,
194    pub quote_id: String,
195    pub market_id: String,
196    pub side: QuoteSide,
197    pub amount_in_atoms: String,
198    /// The same signed minimum returned by the challenge. Preparation may fail,
199    /// but it may never weaken this value.
200    pub minimum_output_atoms: String,
201    /// Partially signed Solana v0 transaction. The session signature slot is
202    /// deliberately empty and must be filled locally.
203    pub transaction_base64: String,
204    pub recent_blockhash: String,
205    pub last_valid_block_height: u64,
206    pub expires_at_ms: u64,
207}
208
209#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
210#[serde(deny_unknown_fields)]
211pub struct ExecutionSubmitRequest {
212    pub execution_id: String,
213    pub signed_transaction_base64: String,
214    /// Caller-generated opaque key. Repeating it may return the original
215    /// result, but can never create a second execution.
216    pub idempotency_key: String,
217}
218
219#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
220#[serde(deny_unknown_fields)]
221pub struct ExecutionSubmitResponse {
222    pub schema_version: u16,
223    pub contract_version: String,
224    pub execution_id: String,
225    pub signature: String,
226    pub status: ExecutionStatus,
227}
228
229#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
230#[serde(rename_all = "snake_case")]
231pub enum ExecutionStatus {
232    Submitted,
233}
234
235#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
236#[serde(deny_unknown_fields)]
237pub struct Market {
238    pub base: String,
239    pub quote: String,
240    pub market_pda: Option<String>,
241    pub label: String,
242    /// Whether the public Sonar quote operation is enabled for this market.
243    /// Liquidity remains live state and a quote can still be temporarily
244    /// unavailable.
245    pub ready: bool,
246    pub base_decimals: u8,
247    pub quote_decimals: u8,
248    /// Stable product-level operation for a Sonar quote. Its implementation
249    /// remains opaque.
250    pub quote_path: Option<String>,
251}
252
253#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
254#[serde(deny_unknown_fields)]
255pub struct MarketsResponse {
256    pub schema_version: u16,
257    pub contract_version: String,
258    pub markets: Vec<Market>,
259}
260
261impl MarketsResponse {
262    pub fn new(markets: Vec<Market>) -> Self {
263        Self {
264            schema_version: CONTRACT_MAJOR,
265            contract_version: CONTRACT_VERSION.to_owned(),
266            markets,
267        }
268    }
269}
270
271#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
272#[serde(deny_unknown_fields)]
273pub struct ErrorDetail {
274    pub code: String,
275    pub message: String,
276    pub retryable: bool,
277}
278
279#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
280#[serde(deny_unknown_fields)]
281pub struct ErrorResponse {
282    pub schema_version: u16,
283    pub contract_version: String,
284    pub error: ErrorDetail,
285}
286
287impl ErrorResponse {
288    pub fn new(code: impl Into<String>, message: impl Into<String>, retryable: bool) -> Self {
289        Self {
290            schema_version: CONTRACT_MAJOR,
291            contract_version: CONTRACT_VERSION.to_owned(),
292            error: ErrorDetail {
293                code: code.into(),
294                message: message.into(),
295                retryable,
296            },
297        }
298    }
299}
300
301#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
302#[serde(rename_all = "snake_case")]
303pub enum CapabilityStability {
304    Internal,
305    Beta,
306    Stable,
307}
308
309#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
310#[serde(rename_all = "snake_case")]
311pub enum CapabilityRisk {
312    Read,
313    Prepare,
314    Submit,
315    Destructive,
316}
317
318#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
319#[serde(rename_all = "snake_case")]
320pub enum McpExposure {
321    None,
322    Read,
323    Prepare,
324    Submit,
325}
326
327#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
328#[serde(deny_unknown_fields)]
329pub struct CapabilityDescriptor {
330    pub id: String,
331    pub introduced_in: String,
332    pub stability: CapabilityStability,
333    pub required_scope: String,
334    pub risk: CapabilityRisk,
335    pub default_enabled: bool,
336    pub public_sdk: bool,
337    pub mcp_exposure: McpExposure,
338}
339
340#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
341#[serde(deny_unknown_fields)]
342pub struct CapabilityCatalog {
343    pub schema_version: u16,
344    pub contract_version: String,
345    pub capabilities: Vec<CapabilityDescriptor>,
346}
347
348impl CapabilityCatalog {
349    pub fn foundation() -> Self {
350        use CapabilityRisk::{Prepare, Read, Submit};
351        use CapabilityStability::{Beta, Stable};
352        use McpExposure::{
353            None as McpNone, Prepare as McpPrepare, Read as McpRead, Submit as McpSubmit,
354        };
355
356        let capability = |id: &str,
357                          introduced_in: &str,
358                          stability,
359                          scope: &str,
360                          risk,
361                          default_enabled,
362                          public_sdk,
363                          mcp_exposure| {
364            CapabilityDescriptor {
365                id: id.to_owned(),
366                introduced_in: introduced_in.to_owned(),
367                stability,
368                required_scope: scope.to_owned(),
369                risk,
370                default_enabled,
371                public_sdk,
372                mcp_exposure,
373            }
374        };
375
376        Self {
377            schema_version: CONTRACT_MAJOR,
378            contract_version: CONTRACT_VERSION.to_owned(),
379            capabilities: vec![
380                capability(
381                    "markets.read",
382                    "1.0",
383                    Stable,
384                    "market:read",
385                    Read,
386                    true,
387                    true,
388                    McpRead,
389                ),
390                capability(
391                    "books.read",
392                    "1.1",
393                    Beta,
394                    "market:read",
395                    Read,
396                    true,
397                    true,
398                    McpNone,
399                ),
400                capability(
401                    "quotes.read",
402                    "1.0",
403                    Beta,
404                    "market:read",
405                    Read,
406                    true,
407                    true,
408                    McpRead,
409                ),
410                capability(
411                    "account.read",
412                    "1.1",
413                    Beta,
414                    "account:read",
415                    Read,
416                    true,
417                    true,
418                    McpNone,
419                ),
420                capability(
421                    "trade.prepare",
422                    "1.1",
423                    Beta,
424                    "trade:prepare",
425                    Prepare,
426                    true,
427                    true,
428                    McpPrepare,
429                ),
430                capability(
431                    "trade.submit",
432                    "1.1",
433                    Beta,
434                    "trade:submit",
435                    Submit,
436                    true,
437                    true,
438                    McpSubmit,
439                ),
440            ],
441        }
442    }
443}
444
445impl ActionGraph {
446    /// Build the stable action topology with availability projected from the
447    /// live capability catalog. Static documentation never grants access: a
448    /// callable node is available only when every required capability is live.
449    pub fn for_catalog(catalog: &CapabilityCatalog) -> Self {
450        let enabled = |required: &[&str]| {
451            required.iter().all(|id| {
452                catalog.capabilities.iter().any(|capability| {
453                    capability.id == *id && capability.default_enabled && capability.public_sdk
454                })
455            })
456        };
457        let operation = |method: &str, path: &str, mcp_tool: Option<&str>| ActionOperation {
458            method: method.to_owned(),
459            path: path.to_owned(),
460            mcp_tool: mcp_tool.map(str::to_owned),
461        };
462        let node = |id: &str,
463                    kind,
464                    summary: &str,
465                    required: &[&str],
466                    operation: Option<ActionOperation>| ActionNode {
467            id: id.to_owned(),
468            kind,
469            summary: summary.to_owned(),
470            required_capabilities: required.iter().map(|value| (*value).to_owned()).collect(),
471            available: operation.is_none() || enabled(required),
472            operation,
473        };
474        let edge = |from: &str, to: &str, condition: &str| ActionEdge {
475            from: from.to_owned(),
476            to: to.to_owned(),
477            condition: condition.to_owned(),
478        };
479
480        Self {
481            schema_version: CONTRACT_MAJOR,
482            graph_version: ACTION_GRAPH_VERSION.to_owned(),
483            contract_version: CONTRACT_VERSION.to_owned(),
484            entry_node: "discover_capabilities".to_owned(),
485            authority: ActionAuthorityModel {
486                permission_source: "external_agent_owner".to_owned(),
487                signing_location: "external".to_owned(),
488                accepts_private_keys: false,
489            },
490            nodes: vec![
491                node(
492                    "discover_capabilities",
493                    ActionNodeKind::Discovery,
494                    "Read the live capabilities that currently expose Strata operations.",
495                    &[],
496                    Some(operation("GET", "/sonar/capabilities", Some("strata_capabilities"))),
497                ),
498                node(
499                    "discover_markets",
500                    ActionNodeKind::Discovery,
501                    "Discover ready markets, token decimals, and public operation paths.",
502                    &["markets.read"],
503                    Some(operation("GET", "/sonar/markets", Some("strata_markets"))),
504                ),
505                node(
506                    "discover_action_graph",
507                    ActionNodeKind::Discovery,
508                    "Read the executable topology, live node availability, external signing steps, and transition conditions.",
509                    &[],
510                    Some(operation("GET", "/sonar/action-graph", Some("strata_action_graph"))),
511                ),
512                node(
513                    "discover_platform_capabilities",
514                    ActionNodeKind::Discovery,
515                    "Read the versioned capabilities available through the official SDK.",
516                    &[],
517                    Some(operation("GET", "/v2/capabilities", None)),
518                ),
519                node(
520                    "discover_platform_markets",
521                    ActionNodeKind::Discovery,
522                    "Discover opaque market IDs and current market status.",
523                    &["markets.read"],
524                    Some(operation("GET", "/v2/markets", None)),
525                ),
526                node(
527                    "read_book",
528                    ActionNodeKind::Read,
529                    "Read a sequenced Strata book snapshot.",
530                    &["books.read"],
531                    Some(operation("GET", "/v2/markets/{market_id}/book", None)),
532                ),
533                node(
534                    "read_market_status",
535                    ActionNodeKind::Read,
536                    "Read tick size, minimum order size, and current market status.",
537                    &["books.read"],
538                    Some(operation("GET", "/v2/markets/{market_id}/status", None)),
539                ),
540                node(
541                    "read_best_bid_ask",
542                    ActionNodeKind::Read,
543                    "Read the current best bid and ask.",
544                    &["books.read"],
545                    Some(operation("GET", "/v2/markets/{market_id}/bbo", None)),
546                ),
547                node(
548                    "read_fees",
549                    ActionNodeKind::Read,
550                    "Read the market fee schedule.",
551                    &["books.read"],
552                    Some(operation("GET", "/v2/markets/{market_id}/fees", None)),
553                ),
554                node(
555                    "read_trades",
556                    ActionNodeKind::Read,
557                    "Read recent anonymized trades.",
558                    &["books.read"],
559                    Some(operation("GET", "/v2/markets/{market_id}/trades", None)),
560                ),
561                node(
562                    "stream_market",
563                    ActionNodeKind::Read,
564                    "Subscribe to book changes, trades, and heartbeats with automatic recovery.",
565                    &["books.read"],
566                    Some(operation("WEBSOCKET", "/v2/markets/{market_id}/stream", None)),
567                ),
568                node(
569                    "authorize_account_read",
570                    ActionNodeKind::ExternalSignature,
571                    "The agent owner's configured signer authorizes the exact account request or stream challenge.",
572                    &[],
573                    None,
574                ),
575                node(
576                    "read_account",
577                    ActionNodeKind::Read,
578                    "Read the owner's sanitized open orders and fills for a Strata market.",
579                    &["account.read"],
580                    Some(operation(
581                        "GET",
582                        "/v2/markets/{market_id}/account/{wallet_address}",
583                        None,
584                    )),
585                ),
586                node(
587                    "stream_account",
588                    ActionNodeKind::Read,
589                    "Subscribe to signed, sequenced order and fill state for the owner.",
590                    &["account.read"],
591                    Some(operation(
592                        "WEBSOCKET",
593                        "/v2/markets/{market_id}/account/{wallet_address}/stream",
594                        None,
595                    )),
596                ),
597                node(
598                    "request_quote",
599                    ActionNodeKind::Read,
600                    "Request economics bound to a market, side, exact input atoms, and tolerance.",
601                    &["quotes.read"],
602                    Some(operation(
603                        "POST",
604                        "/sonar/markets/{market}/quote",
605                        Some("strata_quote"),
606                    )),
607                ),
608                node(
609                    "request_execution_challenge",
610                    ActionNodeKind::Prepare,
611                    "Request canonical authorization bytes for an unexpired quote and external signer.",
612                    &["trade.prepare"],
613                    Some(operation(
614                        "POST",
615                        "/sonar/markets/{market}/execution/challenge",
616                        Some("strata_execution_challenge"),
617                    )),
618                ),
619                node(
620                    "sign_authorization",
621                    ActionNodeKind::ExternalSignature,
622                    "The agent owner's configured signer signs the returned authorization bytes externally.",
623                    &[],
624                    None,
625                ),
626                node(
627                    "prepare_execution",
628                    ActionNodeKind::Prepare,
629                    "Exchange the authorization signature for a quote-bound partially signed transaction.",
630                    &["trade.prepare"],
631                    Some(operation(
632                        "POST",
633                        "/sonar/markets/{market}/execution/prepare",
634                        Some("strata_execution_prepare"),
635                    )),
636                ),
637                node(
638                    "sign_transaction",
639                    ActionNodeKind::ExternalSignature,
640                    "The external signer verifies and fills its signature slot without sending key material to Strata.",
641                    &[],
642                    None,
643                ),
644                node(
645                    "submit_execution",
646                    ActionNodeKind::Submit,
647                    "Submit the signed transaction with an idempotency key.",
648                    &["trade.submit"],
649                    Some(operation(
650                        "POST",
651                        "/sonar/markets/{market}/execution/submit",
652                        Some("strata_execution_submit"),
653                    )),
654                ),
655                node(
656                    "receive_receipt",
657                    ActionNodeKind::Receipt,
658                    "Receive the execution ID, Solana signature, and submitted status.",
659                    &[],
660                    None,
661                ),
662            ],
663            edges: vec![
664                edge("discover_capabilities", "discover_action_graph", "the returned contract version is supported"),
665                edge("discover_action_graph", "discover_markets", "markets.read is enabled"),
666                edge("discover_action_graph", "discover_platform_capabilities", "the versioned SDK contract is supported"),
667                edge("discover_platform_capabilities", "discover_platform_markets", "markets.read is enabled"),
668                edge("discover_platform_markets", "read_book", "books.read is enabled and the market is active"),
669                edge("discover_platform_markets", "read_market_status", "books.read is enabled"),
670                edge("discover_platform_markets", "read_best_bid_ask", "books.read is enabled"),
671                edge("discover_platform_markets", "read_fees", "books.read is enabled"),
672                edge("discover_platform_markets", "read_trades", "books.read is enabled"),
673                edge("read_book", "stream_market", "books.read is enabled and the snapshot sequence is accepted"),
674                edge("discover_platform_markets", "authorize_account_read", "account.read is enabled and the owner-configured signer is available"),
675                edge("authorize_account_read", "read_account", "the signature binds the wallet, market, request time, and fill limit"),
676                edge("read_account", "stream_account", "the stream challenge is signed by the same owner-configured signer"),
677                edge("discover_markets", "request_quote", "quotes.read is enabled and the market is ready"),
678                edge("request_quote", "request_execution_challenge", "trade.prepare is enabled and the quote is unexpired"),
679                edge("request_execution_challenge", "sign_authorization", "the challenge bindings match the quote and signer"),
680                edge("sign_authorization", "prepare_execution", "a valid external authorization signature is available"),
681                edge("prepare_execution", "sign_transaction", "the prepared transaction preserves the signed bindings"),
682                edge("sign_transaction", "submit_execution", "trade.submit is enabled and the signed transaction is unmodified"),
683                edge("submit_execution", "receive_receipt", "the execution ID and idempotency key match"),
684            ],
685        }
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    #[test]
694    fn public_quote_field_set_is_sealed() {
695        let quote: QuoteResponse = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
696        let value = serde_json::to_value(quote).unwrap();
697        let object = value.as_object().unwrap();
698        let mut actual = object.keys().map(String::as_str).collect::<Vec<_>>();
699        actual.sort_unstable();
700        let mut expected = vec![
701            "amount_in_atoms",
702            "amount_in_consumed_atoms",
703            "amount_out_atoms",
704            "contract_version",
705            "expires_at_ms",
706            "input_fee_atoms",
707            "market_id",
708            "minimum_output_atoms",
709            "output_fee_atoms",
710            "price_impact_pct",
711            "provider",
712            "quote_id",
713            "reference_price",
714            "schema_version",
715            "server_time_ms",
716            "side",
717        ];
718        expected.sort_unstable();
719        assert_eq!(actual, expected, "public quote fields must remain sealed");
720        assert_eq!(object["amount_out_atoms"], "1990000");
721        assert_eq!(object["minimum_output_atoms"], "1980050");
722        assert_eq!(object["provider"], "Sonar");
723    }
724
725    #[test]
726    fn reviewed_action_capabilities_are_public_and_typed() {
727        let catalog = CapabilityCatalog::foundation();
728        let prepare = catalog
729            .capabilities
730            .iter()
731            .find(|item| item.id == "trade.prepare")
732            .unwrap();
733        let submit = catalog
734            .capabilities
735            .iter()
736            .find(|item| item.id == "trade.submit")
737            .unwrap();
738        assert!(prepare.default_enabled && prepare.public_sdk);
739        assert_eq!(prepare.risk, CapabilityRisk::Prepare);
740        assert_eq!(prepare.mcp_exposure, McpExposure::Prepare);
741        assert!(submit.default_enabled && submit.public_sdk);
742        assert_eq!(submit.risk, CapabilityRisk::Submit);
743        assert_eq!(submit.mcp_exposure, McpExposure::Submit);
744    }
745
746    #[test]
747    fn shared_v1_fixtures_decode_strictly() {
748        let quote: QuoteResponse = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
749        let markets: MarketsResponse = serde_json::from_str(contract_fixtures::MARKETS).unwrap();
750        let capabilities: CapabilityCatalog =
751            serde_json::from_str(contract_fixtures::CAPABILITIES).unwrap();
752        let action_graph: ActionGraph =
753            serde_json::from_str(contract_fixtures::ACTION_GRAPH).unwrap();
754
755        assert_eq!(quote.contract_version, CONTRACT_VERSION);
756        assert_eq!(markets.contract_version, CONTRACT_VERSION);
757        assert_eq!(capabilities, CapabilityCatalog::foundation());
758        assert_eq!(action_graph, ActionGraph::for_catalog(&capabilities));
759    }
760
761    #[test]
762    fn strict_contract_rejects_unreviewed_quote_fields() {
763        let mut value: serde_json::Value = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
764        value
765            .as_object_mut()
766            .unwrap()
767            .insert("unexpected_field".to_owned(), serde_json::json!("hidden"));
768
769        assert!(serde_json::from_value::<QuoteResponse>(value).is_err());
770    }
771
772    #[test]
773    fn execution_contract_exposes_only_minimum_output_protection() {
774        let challenge: ExecutionChallengeResponse =
775            serde_json::from_str(contract_fixtures::EXECUTION_CHALLENGE).unwrap();
776        let prepared: ExecutionPrepareResponse =
777            serde_json::from_str(contract_fixtures::EXECUTION_PREPARE).unwrap();
778        let submitted: ExecutionSubmitResponse =
779            serde_json::from_str(contract_fixtures::EXECUTION_SUBMIT).unwrap();
780
781        assert_eq!(
782            challenge.minimum_output_atoms,
783            prepared.minimum_output_atoms
784        );
785        assert_eq!(challenge.quote_id, prepared.quote_id);
786        assert_eq!(challenge.market_id, prepared.market_id);
787        assert_eq!(submitted.execution_id, prepared.execution_id);
788
789        for fixture in [
790            contract_fixtures::EXECUTION_CHALLENGE,
791            contract_fixtures::EXECUTION_PREPARE,
792            contract_fixtures::EXECUTION_SUBMIT,
793        ] {
794            let value: serde_json::Value = serde_json::from_str(fixture).unwrap();
795            let keys = value.as_object().unwrap().keys().collect::<Vec<_>>();
796            for forbidden in [
797                "route",
798                "venue",
799                "layer",
800                "plan",
801                "collar",
802                "limit_price",
803                "internal",
804                "l3",
805                "footprint",
806            ] {
807                assert!(
808                    keys.iter().all(|key| !key.contains(forbidden)),
809                    "execution contract exposed forbidden field containing {forbidden}"
810                );
811            }
812        }
813    }
814}