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    /// User-net output after `output_fee_atoms`. Gross route output for
135    /// external route-quality comparison is their exact atomic sum.
136    pub amount_out_atoms: String,
137    /// User-net execution floor after fees and requested tolerance.
138    pub minimum_output_atoms: String,
139    /// Fees charged in the request's input asset. Sonar can charge fees on
140    /// either side, so a single unlabelled fee is unsafe.
141    pub input_fee_atoms: String,
142    /// Strata fee charged in the response's output asset. It is reported
143    /// separately so route quality and all-in user economics cannot be mixed.
144    pub output_fee_atoms: String,
145    /// Display-only decimal strings. SDKs may parse these for presentation but
146    /// must not use them for settlement or signing bounds.
147    pub reference_price: String,
148    pub price_impact_pct: String,
149    pub provider: String,
150}
151
152/// Ask Strata for a one-time payload authorizing preparation of an existing
153/// Sonar quote. The session key signs locally; no private signing material is
154/// accepted by this contract.
155#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
156#[serde(deny_unknown_fields)]
157pub struct ExecutionChallengeRequest {
158    pub quote_id: String,
159    pub owner_wallet: String,
160    pub session_public_key: String,
161    /// Vault-owned Market account sequence encoded as an unsigned decimal
162    /// string. It prevents a prepared internal fill from targeting stale state.
163    pub account_sequence: String,
164}
165
166#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
167#[serde(deny_unknown_fields)]
168pub struct ExecutionChallengeResponse {
169    pub schema_version: u16,
170    pub contract_version: String,
171    pub challenge_id: String,
172    pub quote_id: String,
173    pub market_id: String,
174    pub side: QuoteSide,
175    pub amount_in_atoms: String,
176    /// The sole customer-facing execution protection.
177    pub minimum_output_atoms: String,
178    /// Canonical bytes to sign locally with the declared session key.
179    pub authorization_payload_base64: String,
180    pub server_time_ms: u64,
181    pub expires_at_ms: u64,
182}
183
184#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
185#[serde(deny_unknown_fields)]
186pub struct ExecutionPrepareRequest {
187    pub challenge_id: String,
188    /// Base58 Ed25519 signature over `authorization_payload_base64`.
189    pub authorization_signature: String,
190}
191
192#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
193#[serde(deny_unknown_fields)]
194pub struct ExecutionPrepareResponse {
195    pub schema_version: u16,
196    pub contract_version: String,
197    pub execution_id: String,
198    pub quote_id: String,
199    pub market_id: String,
200    pub side: QuoteSide,
201    pub amount_in_atoms: String,
202    /// The same signed minimum returned by the challenge. Preparation may fail,
203    /// but it may never weaken this value.
204    pub minimum_output_atoms: String,
205    /// Partially signed Solana v0 transaction. The session signature slot is
206    /// deliberately empty and must be filled locally.
207    pub transaction_base64: String,
208    pub recent_blockhash: String,
209    pub last_valid_block_height: u64,
210    pub expires_at_ms: u64,
211}
212
213#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
214#[serde(deny_unknown_fields)]
215pub struct ExecutionSubmitRequest {
216    pub execution_id: String,
217    pub signed_transaction_base64: String,
218    /// Caller-generated opaque key. Repeating it may return the original
219    /// result, but can never create a second execution.
220    pub idempotency_key: String,
221}
222
223#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
224#[serde(deny_unknown_fields)]
225pub struct ExecutionSubmitResponse {
226    pub schema_version: u16,
227    pub contract_version: String,
228    pub execution_id: String,
229    pub signature: String,
230    pub status: ExecutionStatus,
231}
232
233#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
234#[serde(rename_all = "snake_case")]
235pub enum ExecutionStatus {
236    Submitted,
237}
238
239#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
240#[serde(deny_unknown_fields)]
241pub struct Market {
242    pub base: String,
243    pub quote: String,
244    pub market_pda: Option<String>,
245    pub label: String,
246    /// Whether the public Sonar quote operation is enabled for this market.
247    /// Liquidity remains live state and a quote can still be temporarily
248    /// unavailable.
249    pub ready: bool,
250    pub base_decimals: u8,
251    pub quote_decimals: u8,
252    /// Stable product-level operation for a Sonar quote. Its implementation
253    /// remains opaque.
254    pub quote_path: Option<String>,
255}
256
257#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
258#[serde(deny_unknown_fields)]
259pub struct MarketsResponse {
260    pub schema_version: u16,
261    pub contract_version: String,
262    pub markets: Vec<Market>,
263}
264
265impl MarketsResponse {
266    pub fn new(markets: Vec<Market>) -> Self {
267        Self {
268            schema_version: CONTRACT_MAJOR,
269            contract_version: CONTRACT_VERSION.to_owned(),
270            markets,
271        }
272    }
273}
274
275#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
276#[serde(deny_unknown_fields)]
277pub struct ErrorDetail {
278    pub code: String,
279    pub message: String,
280    pub retryable: bool,
281}
282
283#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
284#[serde(deny_unknown_fields)]
285pub struct ErrorResponse {
286    pub schema_version: u16,
287    pub contract_version: String,
288    pub error: ErrorDetail,
289}
290
291impl ErrorResponse {
292    pub fn new(code: impl Into<String>, message: impl Into<String>, retryable: bool) -> Self {
293        Self {
294            schema_version: CONTRACT_MAJOR,
295            contract_version: CONTRACT_VERSION.to_owned(),
296            error: ErrorDetail {
297                code: code.into(),
298                message: message.into(),
299                retryable,
300            },
301        }
302    }
303}
304
305#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
306#[serde(rename_all = "snake_case")]
307pub enum CapabilityStability {
308    Internal,
309    Beta,
310    Stable,
311}
312
313#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
314#[serde(rename_all = "snake_case")]
315pub enum CapabilityRisk {
316    Read,
317    Prepare,
318    Submit,
319    Destructive,
320}
321
322#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
323#[serde(rename_all = "snake_case")]
324pub enum McpExposure {
325    None,
326    Read,
327    Prepare,
328    Submit,
329}
330
331#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
332#[serde(deny_unknown_fields)]
333pub struct CapabilityDescriptor {
334    pub id: String,
335    pub introduced_in: String,
336    pub stability: CapabilityStability,
337    pub required_scope: String,
338    pub risk: CapabilityRisk,
339    pub default_enabled: bool,
340    pub public_sdk: bool,
341    pub mcp_exposure: McpExposure,
342}
343
344#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
345#[serde(deny_unknown_fields)]
346pub struct CapabilityCatalog {
347    pub schema_version: u16,
348    pub contract_version: String,
349    pub capabilities: Vec<CapabilityDescriptor>,
350}
351
352impl CapabilityCatalog {
353    pub fn foundation() -> Self {
354        use CapabilityRisk::{Prepare, Read, Submit};
355        use CapabilityStability::{Beta, Stable};
356        use McpExposure::{
357            None as McpNone, Prepare as McpPrepare, Read as McpRead, Submit as McpSubmit,
358        };
359
360        let capability = |id: &str,
361                          introduced_in: &str,
362                          stability,
363                          scope: &str,
364                          risk,
365                          default_enabled,
366                          public_sdk,
367                          mcp_exposure| {
368            CapabilityDescriptor {
369                id: id.to_owned(),
370                introduced_in: introduced_in.to_owned(),
371                stability,
372                required_scope: scope.to_owned(),
373                risk,
374                default_enabled,
375                public_sdk,
376                mcp_exposure,
377            }
378        };
379
380        Self {
381            schema_version: CONTRACT_MAJOR,
382            contract_version: CONTRACT_VERSION.to_owned(),
383            capabilities: vec![
384                capability(
385                    "markets.read",
386                    "1.0",
387                    Stable,
388                    "market:read",
389                    Read,
390                    true,
391                    true,
392                    McpRead,
393                ),
394                capability(
395                    "books.read",
396                    "1.1",
397                    Beta,
398                    "market:read",
399                    Read,
400                    true,
401                    true,
402                    McpNone,
403                ),
404                capability(
405                    "quotes.read",
406                    "1.0",
407                    Beta,
408                    "market:read",
409                    Read,
410                    true,
411                    true,
412                    McpRead,
413                ),
414                capability(
415                    "account.read",
416                    "1.1",
417                    Beta,
418                    "account:read",
419                    Read,
420                    true,
421                    true,
422                    McpNone,
423                ),
424                capability(
425                    "trade.prepare",
426                    "1.1",
427                    Beta,
428                    "trade:prepare",
429                    Prepare,
430                    true,
431                    true,
432                    McpPrepare,
433                ),
434                capability(
435                    "trade.submit",
436                    "1.1",
437                    Beta,
438                    "trade:submit",
439                    Submit,
440                    true,
441                    true,
442                    McpSubmit,
443                ),
444                capability(
445                    "orders.prepare",
446                    "1.1",
447                    Beta,
448                    "orders:prepare",
449                    Prepare,
450                    false,
451                    true,
452                    McpPrepare,
453                ),
454                capability(
455                    "orders.submit",
456                    "1.1",
457                    Beta,
458                    "orders:submit",
459                    Submit,
460                    false,
461                    true,
462                    McpSubmit,
463                ),
464            ],
465        }
466    }
467}
468
469impl ActionGraph {
470    /// Build the stable action topology with availability projected from the
471    /// live capability catalog. Static documentation never grants access: a
472    /// callable node is available only when every required capability is live.
473    pub fn for_catalog(catalog: &CapabilityCatalog) -> Self {
474        let enabled = |required: &[&str]| {
475            required.iter().all(|id| {
476                catalog.capabilities.iter().any(|capability| {
477                    capability.id == *id && capability.default_enabled && capability.public_sdk
478                })
479            })
480        };
481        let operation = |method: &str, path: &str, mcp_tool: Option<&str>| ActionOperation {
482            method: method.to_owned(),
483            path: path.to_owned(),
484            mcp_tool: mcp_tool.map(str::to_owned),
485        };
486        let node = |id: &str,
487                    kind,
488                    summary: &str,
489                    required: &[&str],
490                    operation: Option<ActionOperation>| ActionNode {
491            id: id.to_owned(),
492            kind,
493            summary: summary.to_owned(),
494            required_capabilities: required.iter().map(|value| (*value).to_owned()).collect(),
495            available: operation.is_none() || enabled(required),
496            operation,
497        };
498        let edge = |from: &str, to: &str, condition: &str| ActionEdge {
499            from: from.to_owned(),
500            to: to.to_owned(),
501            condition: condition.to_owned(),
502        };
503
504        Self {
505            schema_version: CONTRACT_MAJOR,
506            graph_version: ACTION_GRAPH_VERSION.to_owned(),
507            contract_version: CONTRACT_VERSION.to_owned(),
508            entry_node: "discover_capabilities".to_owned(),
509            authority: ActionAuthorityModel {
510                permission_source: "external_agent_owner".to_owned(),
511                signing_location: "external".to_owned(),
512                accepts_private_keys: false,
513            },
514            nodes: vec![
515                node(
516                    "discover_capabilities",
517                    ActionNodeKind::Discovery,
518                    "Read the live capabilities that currently expose Strata operations.",
519                    &[],
520                    Some(operation("GET", "/sonar/capabilities", Some("strata_capabilities"))),
521                ),
522                node(
523                    "discover_markets",
524                    ActionNodeKind::Discovery,
525                    "Discover ready markets, token decimals, and public operation paths.",
526                    &["markets.read"],
527                    Some(operation("GET", "/sonar/markets", Some("strata_markets"))),
528                ),
529                node(
530                    "discover_action_graph",
531                    ActionNodeKind::Discovery,
532                    "Read the executable topology, live node availability, external signing steps, and transition conditions.",
533                    &[],
534                    Some(operation("GET", "/sonar/action-graph", Some("strata_action_graph"))),
535                ),
536                node(
537                    "discover_platform_capabilities",
538                    ActionNodeKind::Discovery,
539                    "Read the versioned capabilities available through the official SDK.",
540                    &[],
541                    Some(operation("GET", "/v2/capabilities", None)),
542                ),
543                node(
544                    "discover_platform_markets",
545                    ActionNodeKind::Discovery,
546                    "Discover opaque market IDs and current market status.",
547                    &["markets.read"],
548                    Some(operation("GET", "/v2/markets", None)),
549                ),
550                node(
551                    "read_book",
552                    ActionNodeKind::Read,
553                    "Read a sequenced Strata book snapshot.",
554                    &["books.read"],
555                    Some(operation("GET", "/v2/markets/{market_id}/book", None)),
556                ),
557                node(
558                    "read_market_status",
559                    ActionNodeKind::Read,
560                    "Read tick size, minimum order size, and current market status.",
561                    &["books.read"],
562                    Some(operation("GET", "/v2/markets/{market_id}/status", None)),
563                ),
564                node(
565                    "read_best_bid_ask",
566                    ActionNodeKind::Read,
567                    "Read the current best bid and ask.",
568                    &["books.read"],
569                    Some(operation("GET", "/v2/markets/{market_id}/bbo", None)),
570                ),
571                node(
572                    "read_fees",
573                    ActionNodeKind::Read,
574                    "Read the market fee schedule.",
575                    &["books.read"],
576                    Some(operation("GET", "/v2/markets/{market_id}/fees", None)),
577                ),
578                node(
579                    "read_trades",
580                    ActionNodeKind::Read,
581                    "Read recent anonymized trades.",
582                    &["books.read"],
583                    Some(operation("GET", "/v2/markets/{market_id}/trades", None)),
584                ),
585                node(
586                    "stream_market",
587                    ActionNodeKind::Read,
588                    "Subscribe to book changes, trades, and heartbeats with automatic recovery.",
589                    &["books.read"],
590                    Some(operation("WEBSOCKET", "/v2/markets/{market_id}/stream", None)),
591                ),
592                node(
593                    "authorize_account_read",
594                    ActionNodeKind::ExternalSignature,
595                    "The agent owner's configured signer authorizes the exact account request or stream challenge.",
596                    &[],
597                    None,
598                ),
599                node(
600                    "read_account",
601                    ActionNodeKind::Read,
602                    "Read the owner's sanitized open orders and fills for a Strata market.",
603                    &["account.read"],
604                    Some(operation(
605                        "GET",
606                        "/v2/markets/{market_id}/account/{wallet_address}",
607                        None,
608                    )),
609                ),
610                node(
611                    "stream_account",
612                    ActionNodeKind::Read,
613                    "Subscribe to signed, sequenced order and fill state for the owner.",
614                    &["account.read"],
615                    Some(operation(
616                        "WEBSOCKET",
617                        "/v2/markets/{market_id}/account/{wallet_address}/stream",
618                        None,
619                    )),
620                ),
621                node(
622                    "request_quote",
623                    ActionNodeKind::Read,
624                    "Request economics bound to a market, side, exact input atoms, and tolerance.",
625                    &["quotes.read"],
626                    Some(operation(
627                        "POST",
628                        "/sonar/markets/{market}/quote",
629                        Some("strata_quote"),
630                    )),
631                ),
632                node(
633                    "request_execution_challenge",
634                    ActionNodeKind::Prepare,
635                    "Request canonical authorization bytes for an unexpired quote and external signer.",
636                    &["trade.prepare"],
637                    Some(operation(
638                        "POST",
639                        "/sonar/markets/{market}/execution/challenge",
640                        Some("strata_execution_challenge"),
641                    )),
642                ),
643                node(
644                    "sign_authorization",
645                    ActionNodeKind::ExternalSignature,
646                    "The agent owner's configured signer signs the returned authorization bytes externally.",
647                    &[],
648                    None,
649                ),
650                node(
651                    "prepare_execution",
652                    ActionNodeKind::Prepare,
653                    "Exchange the authorization signature for a quote-bound partially signed transaction.",
654                    &["trade.prepare"],
655                    Some(operation(
656                        "POST",
657                        "/sonar/markets/{market}/execution/prepare",
658                        Some("strata_execution_prepare"),
659                    )),
660                ),
661                node(
662                    "sign_transaction",
663                    ActionNodeKind::ExternalSignature,
664                    "The external signer verifies and fills its signature slot without sending key material to Strata.",
665                    &[],
666                    None,
667                ),
668                node(
669                    "submit_execution",
670                    ActionNodeKind::Submit,
671                    "Submit the signed transaction with an idempotency key.",
672                    &["trade.submit"],
673                    Some(operation(
674                        "POST",
675                        "/sonar/markets/{market}/execution/submit",
676                        Some("strata_execution_submit"),
677                    )),
678                ),
679                node(
680                    "receive_receipt",
681                    ActionNodeKind::Receipt,
682                    "Receive the execution ID, Solana signature, and submitted status.",
683                    &[],
684                    None,
685                ),
686                node(
687                    "request_order_challenge",
688                    ActionNodeKind::Prepare,
689                    "Bind a product-level place, cancel, bounded cancel-all, atomic replace, or atomic batch operation to canonical authorization bytes.",
690                    &["orders.prepare"],
691                    Some(operation(
692                        "POST",
693                        "/v2/markets/{market_id}/orders/challenge",
694                        Some("strata_order_challenge"),
695                    )),
696                ),
697                node(
698                    "sign_order_authorization",
699                    ActionNodeKind::ExternalSignature,
700                    "The agent owner's configured session signer verifies the exact order set and signs externally.",
701                    &[],
702                    None,
703                ),
704                node(
705                    "prepare_order_control",
706                    ActionNodeKind::Prepare,
707                    "Exchange the order authorization signature for a partially signed transaction.",
708                    &["orders.prepare"],
709                    Some(operation(
710                        "POST",
711                        "/v2/markets/{market_id}/orders/prepare",
712                        Some("strata_order_prepare"),
713                    )),
714                ),
715                node(
716                    "sign_order_transaction",
717                    ActionNodeKind::ExternalSignature,
718                    "The external session signer verifies and fills only its transaction signature slot.",
719                    &[],
720                    None,
721                ),
722                node(
723                    "submit_order_control",
724                    ActionNodeKind::Submit,
725                    "Submit the unchanged signed order transaction with an idempotency key.",
726                    &["orders.submit"],
727                    Some(operation(
728                        "POST",
729                        "/v2/markets/{market_id}/orders/submit",
730                        Some("strata_order_submit"),
731                    )),
732                ),
733                node(
734                    "receive_order_receipt",
735                    ActionNodeKind::Receipt,
736                    "Receive the opaque order IDs, transaction signature, and submitted status.",
737                    &[],
738                    None,
739                ),
740                node(
741                    "recover_order_status",
742                    ActionNodeKind::Read,
743                    "Recover durable submitting, submitted, or failed status after a timeout or restart.",
744                    &["orders.submit"],
745                    Some(operation(
746                        "POST",
747                        "/v2/markets/{market_id}/orders/status",
748                        Some("strata_order_status"),
749                    )),
750                ),
751            ],
752            edges: vec![
753                edge("discover_capabilities", "discover_action_graph", "the returned contract version is supported"),
754                edge("discover_action_graph", "discover_markets", "markets.read is enabled"),
755                edge("discover_action_graph", "discover_platform_capabilities", "the versioned SDK contract is supported"),
756                edge("discover_platform_capabilities", "discover_platform_markets", "markets.read is enabled"),
757                edge("discover_platform_markets", "read_book", "books.read is enabled and the market is active"),
758                edge("discover_platform_markets", "read_market_status", "books.read is enabled"),
759                edge("discover_platform_markets", "read_best_bid_ask", "books.read is enabled"),
760                edge("discover_platform_markets", "read_fees", "books.read is enabled"),
761                edge("discover_platform_markets", "read_trades", "books.read is enabled"),
762                edge("read_book", "stream_market", "books.read is enabled and the snapshot sequence is accepted"),
763                edge("discover_platform_markets", "authorize_account_read", "account.read is enabled and the owner-configured signer is available"),
764                edge("authorize_account_read", "read_account", "the signature binds the wallet, market, request time, and fill limit"),
765                edge("read_account", "stream_account", "the stream challenge is signed by the same owner-configured signer"),
766                edge("discover_markets", "request_quote", "quotes.read is enabled and the market is ready"),
767                edge("request_quote", "request_execution_challenge", "trade.prepare is enabled and the quote is unexpired"),
768                edge("request_execution_challenge", "sign_authorization", "the challenge bindings match the quote and signer"),
769                edge("sign_authorization", "prepare_execution", "a valid external authorization signature is available"),
770                edge("prepare_execution", "sign_transaction", "the prepared transaction preserves the signed bindings"),
771                edge("sign_transaction", "submit_execution", "trade.submit is enabled and the signed transaction is unmodified"),
772                edge("submit_execution", "receive_receipt", "the execution ID and idempotency key match"),
773                edge("discover_platform_markets", "request_order_challenge", "orders.prepare is enabled and the market accepts order control"),
774                edge("request_order_challenge", "sign_order_authorization", "the action and exact opaque order set match owner intent"),
775                edge("sign_order_authorization", "prepare_order_control", "a valid external authorization signature is available"),
776                edge("prepare_order_control", "sign_order_transaction", "the prepared transaction preserves the signed order bindings"),
777                edge("sign_order_transaction", "submit_order_control", "orders.submit is enabled and the signed transaction is unmodified"),
778                edge("submit_order_control", "receive_order_receipt", "the control ID and idempotency key match"),
779                edge("submit_order_control", "recover_order_status", "the submission result is ambiguous or either process restarted"),
780                edge("recover_order_status", "receive_order_receipt", "durable status is submitted"),
781            ],
782        }
783    }
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789
790    #[test]
791    fn public_quote_field_set_is_sealed() {
792        let quote: QuoteResponse = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
793        let value = serde_json::to_value(quote).unwrap();
794        let object = value.as_object().unwrap();
795        let mut actual = object.keys().map(String::as_str).collect::<Vec<_>>();
796        actual.sort_unstable();
797        let mut expected = vec![
798            "amount_in_atoms",
799            "amount_in_consumed_atoms",
800            "amount_out_atoms",
801            "contract_version",
802            "expires_at_ms",
803            "input_fee_atoms",
804            "market_id",
805            "minimum_output_atoms",
806            "output_fee_atoms",
807            "price_impact_pct",
808            "provider",
809            "quote_id",
810            "reference_price",
811            "schema_version",
812            "server_time_ms",
813            "side",
814        ];
815        expected.sort_unstable();
816        assert_eq!(actual, expected, "public quote fields must remain sealed");
817        assert_eq!(object["amount_out_atoms"], "1990000");
818        assert_eq!(object["minimum_output_atoms"], "1980050");
819        assert_eq!(object["provider"], "Sonar");
820    }
821
822    #[test]
823    fn reviewed_action_capabilities_are_public_and_typed() {
824        let catalog = CapabilityCatalog::foundation();
825        let prepare = catalog
826            .capabilities
827            .iter()
828            .find(|item| item.id == "trade.prepare")
829            .unwrap();
830        let submit = catalog
831            .capabilities
832            .iter()
833            .find(|item| item.id == "trade.submit")
834            .unwrap();
835        assert!(prepare.default_enabled && prepare.public_sdk);
836        assert_eq!(prepare.risk, CapabilityRisk::Prepare);
837        assert_eq!(prepare.mcp_exposure, McpExposure::Prepare);
838        assert!(submit.default_enabled && submit.public_sdk);
839        assert_eq!(submit.risk, CapabilityRisk::Submit);
840        assert_eq!(submit.mcp_exposure, McpExposure::Submit);
841    }
842
843    #[test]
844    fn shared_v1_fixtures_decode_strictly() {
845        let quote: QuoteResponse = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
846        let markets: MarketsResponse = serde_json::from_str(contract_fixtures::MARKETS).unwrap();
847        let capabilities: CapabilityCatalog =
848            serde_json::from_str(contract_fixtures::CAPABILITIES).unwrap();
849        let action_graph: ActionGraph =
850            serde_json::from_str(contract_fixtures::ACTION_GRAPH).unwrap();
851
852        assert_eq!(quote.contract_version, CONTRACT_VERSION);
853        assert_eq!(markets.contract_version, CONTRACT_VERSION);
854        assert_eq!(capabilities, CapabilityCatalog::foundation());
855        assert_eq!(action_graph, ActionGraph::for_catalog(&capabilities));
856    }
857
858    #[test]
859    fn strict_contract_rejects_unreviewed_quote_fields() {
860        let mut value: serde_json::Value = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
861        value
862            .as_object_mut()
863            .unwrap()
864            .insert("unexpected_field".to_owned(), serde_json::json!("hidden"));
865
866        assert!(serde_json::from_value::<QuoteResponse>(value).is_err());
867    }
868
869    #[test]
870    fn execution_contract_exposes_only_minimum_output_protection() {
871        let challenge: ExecutionChallengeResponse =
872            serde_json::from_str(contract_fixtures::EXECUTION_CHALLENGE).unwrap();
873        let prepared: ExecutionPrepareResponse =
874            serde_json::from_str(contract_fixtures::EXECUTION_PREPARE).unwrap();
875        let submitted: ExecutionSubmitResponse =
876            serde_json::from_str(contract_fixtures::EXECUTION_SUBMIT).unwrap();
877
878        assert_eq!(
879            challenge.minimum_output_atoms,
880            prepared.minimum_output_atoms
881        );
882        assert_eq!(challenge.quote_id, prepared.quote_id);
883        assert_eq!(challenge.market_id, prepared.market_id);
884        assert_eq!(submitted.execution_id, prepared.execution_id);
885
886        for fixture in [
887            contract_fixtures::EXECUTION_CHALLENGE,
888            contract_fixtures::EXECUTION_PREPARE,
889            contract_fixtures::EXECUTION_SUBMIT,
890        ] {
891            let value: serde_json::Value = serde_json::from_str(fixture).unwrap();
892            let keys = value.as_object().unwrap().keys().collect::<Vec<_>>();
893            for forbidden in [
894                "route",
895                "venue",
896                "layer",
897                "plan",
898                "collar",
899                "limit_price",
900                "internal",
901                "l3",
902                "footprint",
903            ] {
904                assert!(
905                    keys.iter().all(|key| !key.contains(forbidden)),
906                    "execution contract exposed forbidden field containing {forbidden}"
907                );
908            }
909        }
910    }
911}