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/// Default maximum tolerance: zero, so a quote is exact unless the caller opts
15/// into a lower floor. Tolerance is the caller's choice; it is not price impact.
16pub const DEFAULT_MAXIMUM_TOLERANCE_BPS: u16 = 0;
17/// Legacy name for [`DEFAULT_MAXIMUM_TOLERANCE_BPS`].
18pub const DEFAULT_SLIPPAGE_BPS: u16 = DEFAULT_MAXIMUM_TOLERANCE_BPS;
19
20/// Canonical v1 examples used to prove cross-language contract parity.
21///
22/// This module is excluded from ordinary production builds and exists only for
23/// crate verification and downstream SDK tests.
24#[cfg(any(test, feature = "fixtures"))]
25#[doc(hidden)]
26pub mod contract_fixtures {
27    pub const ACTION_GRAPH: &str = include_str!("../fixtures/v1/action-graph.json");
28    pub const CAPABILITIES: &str = include_str!("../fixtures/v1/capabilities.json");
29    pub const EXECUTION_CHALLENGE: &str = include_str!("../fixtures/v1/execution-challenge.json");
30    pub const EXECUTION_PREPARE: &str = include_str!("../fixtures/v1/execution-prepare.json");
31    pub const EXECUTION_SUBMIT: &str = include_str!("../fixtures/v1/execution-submit.json");
32    pub const MARKETS: &str = include_str!("../fixtures/v1/markets.json");
33    pub const QUOTE: &str = include_str!("../fixtures/v1/quote.json");
34}
35
36pub const ACTION_GRAPH_VERSION: &str = "1.0";
37
38#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
39#[serde(rename_all = "snake_case")]
40pub enum ActionNodeKind {
41    Discovery,
42    Read,
43    Prepare,
44    ExternalSignature,
45    Submit,
46    Receipt,
47}
48
49#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
50#[serde(deny_unknown_fields)]
51pub struct ActionAuthorityModel {
52    /// Permission and signer policy are configured by the external agent owner.
53    pub permission_source: String,
54    /// Private signing material stays in the owner's agent or wallet runtime.
55    pub signing_location: String,
56    /// Strata accepts public keys and signatures, never private key material.
57    pub accepts_private_keys: bool,
58}
59
60#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
61#[serde(deny_unknown_fields)]
62pub struct ActionOperation {
63    pub method: String,
64    pub path: String,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub mcp_tool: Option<String>,
67}
68
69#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
70#[serde(deny_unknown_fields)]
71pub struct ActionNode {
72    pub id: String,
73    pub kind: ActionNodeKind,
74    pub summary: String,
75    pub required_capabilities: Vec<String>,
76    /// Computed from the live capability catalog for callable Strata nodes.
77    pub available: bool,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub operation: Option<ActionOperation>,
80}
81
82#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
83#[serde(deny_unknown_fields)]
84pub struct ActionEdge {
85    pub from: String,
86    pub to: String,
87    pub condition: String,
88}
89
90#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
91#[serde(deny_unknown_fields)]
92pub struct ActionGraph {
93    pub schema_version: u16,
94    pub graph_version: String,
95    pub contract_version: String,
96    pub entry_node: String,
97    pub authority: ActionAuthorityModel,
98    pub nodes: Vec<ActionNode>,
99    pub edges: Vec<ActionEdge>,
100}
101
102#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
103#[serde(rename_all = "snake_case")]
104pub enum QuoteSide {
105    Buy,
106    Sell,
107}
108
109#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
110#[serde(deny_unknown_fields)]
111pub struct QuoteRequest {
112    pub market_id: String,
113    pub side: QuoteSide,
114    /// Exact-input quote: atomic input amount encoded as a base-10 string.
115    /// Public money values never cross JSON as floating-point numbers. Provide
116    /// exactly one of `amount_in_atoms` and `amount_out_atoms`.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub amount_in_atoms: Option<String>,
119    /// Exact-output quote: the atomic output the caller wants. Strata inverts
120    /// its best route at quote time and returns the input that delivers it as
121    /// `amount_in_atoms` (no cushion of its own); `minimum_output_atoms` is
122    /// this amount lowered by `maximum_tolerance_bps` exactly as for exact
123    /// input — zero by default, so execution delivers the requested amount or
124    /// fails.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub amount_out_atoms: Option<String>,
127    /// The most the caller accepts below the quoted output, in basis points.
128    /// This is the caller's choice and has nothing to do with
129    /// `price_impact_pct`, which is measured from the book. Zero (the
130    /// default) means the quoted output exactly. `slippage_bps` is accepted
131    /// as a legacy spelling.
132    #[serde(alias = "slippage_bps")]
133    pub maximum_tolerance_bps: u16,
134}
135
136#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
137#[serde(deny_unknown_fields)]
138pub struct QuoteResponse {
139    pub schema_version: u16,
140    pub contract_version: String,
141    /// Opaque, short-lived handle. It identifies no execution source and
142    /// carries no readable Sonar plan material.
143    pub quote_id: String,
144    pub server_time_ms: u64,
145    pub expires_at_ms: u64,
146    pub market_id: String,
147    pub side: QuoteSide,
148    pub amount_in_atoms: String,
149    /// Requested input actually consumed by the quoted execution.
150    pub amount_in_consumed_atoms: String,
151    /// User-net output after `output_fee_atoms`. Gross pre-fee output is their
152    /// exact atomic sum.
153    pub amount_out_atoms: String,
154    /// User-net execution floor: `amount_out_atoms` lowered by
155    /// `maximum_tolerance_bps` (after fees). Execution delivers at least this
156    /// or does not happen.
157    pub minimum_output_atoms: String,
158    /// Fees charged in the request's input asset. Sonar can charge fees on
159    /// either side, so a single unlabelled fee is unsafe.
160    pub input_fee_atoms: String,
161    /// Strata fee charged in the response's output asset. It is reported
162    /// separately so pre-fee and all-in user economics cannot be mixed.
163    pub output_fee_atoms: String,
164    /// The caller's tolerance echoed back: the most they accept below
165    /// `amount_out_atoms`, already applied in `minimum_output_atoms`. It is a
166    /// choice, not a measurement — compare `price_impact_pct`.
167    pub maximum_tolerance_bps: u16,
168    /// Display-only decimal strings. SDKs may parse these for presentation but
169    /// must not use them for settlement or signing bounds.
170    /// `reference_price` is the best price before the order; `price_impact_pct`
171    /// is how far the quoted fills' average price sits from it, measured from
172    /// the book. It is not a setting and is unrelated to `maximum_tolerance_bps`.
173    pub reference_price: String,
174    pub price_impact_pct: String,
175    pub provider: String,
176}
177
178/// Ask Strata for a one-time payload authorizing preparation of an existing
179/// Sonar quote. The session key signs locally; no private signing material is
180/// accepted by this contract.
181#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
182#[serde(deny_unknown_fields)]
183pub struct ExecutionChallengeRequest {
184    pub quote_id: String,
185    pub owner_wallet: String,
186    pub session_public_key: String,
187    /// Vault-owned Market account sequence encoded as an unsigned decimal
188    /// string. It prevents a prepared internal fill from targeting stale state.
189    /// Omit it and Strata resolves the next sequence from the Vault's confirmed
190    /// market account when the challenge is issued.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub account_sequence: Option<String>,
193}
194
195#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
196#[serde(deny_unknown_fields)]
197pub struct ExecutionChallengeResponse {
198    pub schema_version: u16,
199    pub contract_version: String,
200    pub challenge_id: String,
201    pub quote_id: String,
202    pub market_id: String,
203    pub side: QuoteSide,
204    pub amount_in_atoms: String,
205    /// The sole customer-facing execution protection.
206    pub minimum_output_atoms: String,
207    /// Canonical bytes to sign locally with the declared session key.
208    pub authorization_payload_base64: String,
209    pub server_time_ms: u64,
210    pub expires_at_ms: u64,
211}
212
213/// A prepared execution challenge, signed: the two-step path.
214#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
215#[serde(deny_unknown_fields)]
216pub struct ExecutionPrepareAuthorization {
217    pub challenge_id: String,
218    /// Base58 Ed25519 signature over `authorization_payload_base64`.
219    pub authorization_signature: String,
220}
221
222/// Prepare a quote-bound execution transaction: a signed challenge
223/// (`Authorized`) or the quote binding itself (`Direct`, one signature — the
224/// session's transaction signature is the authorization). The response is
225/// identical.
226#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
227#[serde(untagged)]
228pub enum ExecutionPrepareRequest {
229    Authorized(ExecutionPrepareAuthorization),
230    Direct(ExecutionChallengeRequest),
231}
232
233#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
234#[serde(deny_unknown_fields)]
235pub struct ExecutionPrepareResponse {
236    pub schema_version: u16,
237    pub contract_version: String,
238    pub execution_id: String,
239    pub quote_id: String,
240    pub market_id: String,
241    pub side: QuoteSide,
242    pub amount_in_atoms: String,
243    /// The same signed minimum returned by the challenge. Preparation may fail,
244    /// but it may never weaken this value.
245    pub minimum_output_atoms: String,
246    /// Partially signed Solana v0 transaction. The session signature slot is
247    /// deliberately empty and must be filled locally.
248    pub transaction_base64: String,
249    pub recent_blockhash: String,
250    pub last_valid_block_height: u64,
251    pub expires_at_ms: u64,
252}
253
254#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
255#[serde(deny_unknown_fields)]
256pub struct ExecutionSubmitRequest {
257    pub execution_id: String,
258    pub signed_transaction_base64: String,
259    /// Caller-generated opaque key. Repeating it may return the original
260    /// result, but can never create a second execution.
261    pub idempotency_key: String,
262}
263
264#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
265#[serde(deny_unknown_fields)]
266pub struct ExecutionSubmitResponse {
267    pub schema_version: u16,
268    pub contract_version: String,
269    pub execution_id: String,
270    pub signature: String,
271    pub status: ExecutionStatus,
272}
273
274#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
275#[serde(rename_all = "snake_case")]
276pub enum ExecutionStatus {
277    Submitted,
278}
279
280#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
281#[serde(deny_unknown_fields)]
282pub struct Market {
283    pub base: String,
284    pub quote: String,
285    pub market_pda: Option<String>,
286    pub label: String,
287    /// Whether the public Sonar quote operation is enabled for this market.
288    /// Liquidity remains live state and a quote can still be temporarily
289    /// unavailable.
290    pub ready: bool,
291    pub base_decimals: u8,
292    pub quote_decimals: u8,
293    /// Stable product-level operation for a Sonar quote. Its implementation
294    /// remains opaque.
295    pub quote_path: Option<String>,
296}
297
298#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
299#[serde(deny_unknown_fields)]
300pub struct MarketsResponse {
301    pub schema_version: u16,
302    pub contract_version: String,
303    pub markets: Vec<Market>,
304}
305
306impl MarketsResponse {
307    pub fn new(markets: Vec<Market>) -> Self {
308        Self {
309            schema_version: CONTRACT_MAJOR,
310            contract_version: CONTRACT_VERSION.to_owned(),
311            markets,
312        }
313    }
314}
315
316#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
317#[serde(deny_unknown_fields)]
318pub struct ErrorDetail {
319    pub code: String,
320    pub message: String,
321    pub retryable: bool,
322}
323
324#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
325#[serde(deny_unknown_fields)]
326pub struct ErrorResponse {
327    pub schema_version: u16,
328    pub contract_version: String,
329    pub error: ErrorDetail,
330}
331
332impl ErrorResponse {
333    pub fn new(code: impl Into<String>, message: impl Into<String>, retryable: bool) -> Self {
334        Self {
335            schema_version: CONTRACT_MAJOR,
336            contract_version: CONTRACT_VERSION.to_owned(),
337            error: ErrorDetail {
338                code: code.into(),
339                message: message.into(),
340                retryable,
341            },
342        }
343    }
344}
345
346#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
347#[serde(rename_all = "snake_case")]
348pub enum CapabilityStability {
349    Internal,
350    Beta,
351    Stable,
352}
353
354#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
355#[serde(rename_all = "snake_case")]
356pub enum CapabilityRisk {
357    Read,
358    Prepare,
359    Submit,
360    Destructive,
361}
362
363#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
364#[serde(rename_all = "snake_case")]
365pub enum McpExposure {
366    None,
367    Read,
368    Prepare,
369    Submit,
370}
371
372#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
373#[serde(deny_unknown_fields)]
374pub struct CapabilityDescriptor {
375    pub id: String,
376    pub introduced_in: String,
377    pub stability: CapabilityStability,
378    pub required_scope: String,
379    pub risk: CapabilityRisk,
380    pub default_enabled: bool,
381    pub public_sdk: bool,
382    pub mcp_exposure: McpExposure,
383}
384
385#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
386#[serde(deny_unknown_fields)]
387pub struct CapabilityCatalog {
388    pub schema_version: u16,
389    pub contract_version: String,
390    pub capabilities: Vec<CapabilityDescriptor>,
391}
392
393impl CapabilityCatalog {
394    pub fn foundation() -> Self {
395        use CapabilityRisk::{Prepare, Read, Submit};
396        use CapabilityStability::{Beta, Stable};
397        use McpExposure::{
398            None as McpNone, Prepare as McpPrepare, Read as McpRead, Submit as McpSubmit,
399        };
400
401        let capability = |id: &str,
402                          introduced_in: &str,
403                          stability,
404                          scope: &str,
405                          risk,
406                          default_enabled,
407                          public_sdk,
408                          mcp_exposure| {
409            CapabilityDescriptor {
410                id: id.to_owned(),
411                introduced_in: introduced_in.to_owned(),
412                stability,
413                required_scope: scope.to_owned(),
414                risk,
415                default_enabled,
416                public_sdk,
417                mcp_exposure,
418            }
419        };
420
421        Self {
422            schema_version: CONTRACT_MAJOR,
423            contract_version: CONTRACT_VERSION.to_owned(),
424            capabilities: vec![
425                capability(
426                    "markets.read",
427                    "1.0",
428                    Stable,
429                    "market:read",
430                    Read,
431                    true,
432                    true,
433                    McpRead,
434                ),
435                capability(
436                    "books.read",
437                    "1.1",
438                    Beta,
439                    "market:read",
440                    Read,
441                    true,
442                    true,
443                    McpNone,
444                ),
445                capability(
446                    "quotes.read",
447                    "1.0",
448                    Beta,
449                    "market:read",
450                    Read,
451                    true,
452                    true,
453                    McpRead,
454                ),
455                capability(
456                    "account.read",
457                    "1.1",
458                    Beta,
459                    "account:read",
460                    Read,
461                    true,
462                    true,
463                    McpNone,
464                ),
465                capability(
466                    "trade.prepare",
467                    "1.1",
468                    Beta,
469                    "trade:prepare",
470                    Prepare,
471                    true,
472                    true,
473                    McpPrepare,
474                ),
475                capability(
476                    "trade.submit",
477                    "1.1",
478                    Beta,
479                    "trade:submit",
480                    Submit,
481                    true,
482                    true,
483                    McpSubmit,
484                ),
485                capability(
486                    "orders.prepare",
487                    "1.1",
488                    Beta,
489                    "orders:prepare",
490                    Prepare,
491                    false,
492                    true,
493                    McpPrepare,
494                ),
495                capability(
496                    "orders.submit",
497                    "1.1",
498                    Beta,
499                    "orders:submit",
500                    Submit,
501                    false,
502                    true,
503                    McpSubmit,
504                ),
505                capability(
506                    "mm.intent.manage",
507                    "1.1",
508                    Beta,
509                    "mm:write",
510                    Submit,
511                    false,
512                    true,
513                    McpSubmit,
514                ),
515                capability(
516                    "mm.strand.manage",
517                    "1.1",
518                    Beta,
519                    "mm:write",
520                    Submit,
521                    false,
522                    true,
523                    McpSubmit,
524                ),
525                capability(
526                    "mm.current.manage",
527                    "1.1",
528                    Beta,
529                    "mm:write",
530                    Submit,
531                    false,
532                    true,
533                    McpSubmit,
534                ),
535            ],
536        }
537    }
538}
539
540impl ActionGraph {
541    /// Build the stable action topology with availability projected from the
542    /// live capability catalog. Static documentation never grants access: a
543    /// callable node is available only when every required capability is live.
544    pub fn for_catalog(catalog: &CapabilityCatalog) -> Self {
545        let enabled = |required: &[&str]| {
546            required.iter().all(|id| {
547                catalog.capabilities.iter().any(|capability| {
548                    capability.id == *id && capability.default_enabled && capability.public_sdk
549                })
550            })
551        };
552        let operation = |method: &str, path: &str, mcp_tool: Option<&str>| ActionOperation {
553            method: method.to_owned(),
554            path: path.to_owned(),
555            mcp_tool: mcp_tool.map(str::to_owned),
556        };
557        let node = |id: &str,
558                    kind,
559                    summary: &str,
560                    required: &[&str],
561                    operation: Option<ActionOperation>| ActionNode {
562            id: id.to_owned(),
563            kind,
564            summary: summary.to_owned(),
565            required_capabilities: required.iter().map(|value| (*value).to_owned()).collect(),
566            available: operation.is_none() || enabled(required),
567            operation,
568        };
569        let edge = |from: &str, to: &str, condition: &str| ActionEdge {
570            from: from.to_owned(),
571            to: to.to_owned(),
572            condition: condition.to_owned(),
573        };
574
575        Self {
576            schema_version: CONTRACT_MAJOR,
577            graph_version: ACTION_GRAPH_VERSION.to_owned(),
578            contract_version: CONTRACT_VERSION.to_owned(),
579            entry_node: "discover_capabilities".to_owned(),
580            authority: ActionAuthorityModel {
581                permission_source: "external_agent_owner".to_owned(),
582                signing_location: "external".to_owned(),
583                accepts_private_keys: false,
584            },
585            nodes: vec![
586                node(
587                    "discover_capabilities",
588                    ActionNodeKind::Discovery,
589                    "Read the live capabilities that currently expose Strata operations.",
590                    &[],
591                    Some(operation("GET", "/sonar/capabilities", Some("strata_capabilities"))),
592                ),
593                node(
594                    "discover_markets",
595                    ActionNodeKind::Discovery,
596                    "Discover ready markets, token decimals, and public operation paths.",
597                    &["markets.read"],
598                    Some(operation("GET", "/sonar/markets", Some("strata_markets"))),
599                ),
600                node(
601                    "discover_action_graph",
602                    ActionNodeKind::Discovery,
603                    "Read the executable topology, live node availability, external signing steps, and transition conditions.",
604                    &[],
605                    Some(operation("GET", "/sonar/action-graph", Some("strata_action_graph"))),
606                ),
607                node(
608                    "discover_platform_capabilities",
609                    ActionNodeKind::Discovery,
610                    "Read the versioned capabilities available through the official SDK.",
611                    &[],
612                    Some(operation("GET", "/v2/capabilities", None)),
613                ),
614                node(
615                    "discover_platform_markets",
616                    ActionNodeKind::Discovery,
617                    "Discover opaque market IDs and current market status.",
618                    &["markets.read"],
619                    Some(operation("GET", "/v2/markets", None)),
620                ),
621                node(
622                    "read_book",
623                    ActionNodeKind::Read,
624                    "Read a sequenced Strata book snapshot.",
625                    &["books.read"],
626                    Some(operation("GET", "/v2/markets/{market_id}/book", None)),
627                ),
628                node(
629                    "read_market_status",
630                    ActionNodeKind::Read,
631                    "Read tick size, the smallest valid base-atom size, and current market status.",
632                    &["books.read"],
633                    Some(operation("GET", "/v2/markets/{market_id}/status", None)),
634                ),
635                node(
636                    "read_best_bid_ask",
637                    ActionNodeKind::Read,
638                    "Read the current best bid and ask.",
639                    &["books.read"],
640                    Some(operation("GET", "/v2/markets/{market_id}/bbo", None)),
641                ),
642                node(
643                    "read_fees",
644                    ActionNodeKind::Read,
645                    "Read the market fee schedule.",
646                    &["books.read"],
647                    Some(operation("GET", "/v2/markets/{market_id}/fees", None)),
648                ),
649                node(
650                    "read_trades",
651                    ActionNodeKind::Read,
652                    "Read recent anonymized trades.",
653                    &["books.read"],
654                    Some(operation("GET", "/v2/markets/{market_id}/trades", None)),
655                ),
656                node(
657                    "stream_market",
658                    ActionNodeKind::Read,
659                    "Subscribe to book changes, trades, and heartbeats with automatic recovery.",
660                    &["books.read"],
661                    Some(operation("WEBSOCKET", "/v2/markets/{market_id}/stream", None)),
662                ),
663                node(
664                    "authorize_account_read",
665                    ActionNodeKind::ExternalSignature,
666                    "The agent owner's configured signer authorizes the exact account request or stream challenge.",
667                    &[],
668                    None,
669                ),
670                node(
671                    "read_account",
672                    ActionNodeKind::Read,
673                    "Read the owner's sanitized open orders and fills for a Strata market.",
674                    &["account.read"],
675                    Some(operation(
676                        "GET",
677                        "/v2/markets/{market_id}/account/{wallet_address}",
678                        None,
679                    )),
680                ),
681                node(
682                    "stream_account",
683                    ActionNodeKind::Read,
684                    "Subscribe to signed, sequenced order and fill state for the owner.",
685                    &["account.read"],
686                    Some(operation(
687                        "WEBSOCKET",
688                        "/v2/markets/{market_id}/account/{wallet_address}/stream",
689                        None,
690                    )),
691                ),
692                node(
693                    "request_quote",
694                    ActionNodeKind::Read,
695                    "Request economics bound to a market, side, exact input atoms, and tolerance.",
696                    &["quotes.read"],
697                    Some(operation(
698                        "POST",
699                        "/sonar/markets/{market}/quote",
700                        Some("strata_quote"),
701                    )),
702                ),
703                node(
704                    "request_execution_challenge",
705                    ActionNodeKind::Prepare,
706                    "Request canonical authorization bytes for an unexpired quote and external signer.",
707                    &["trade.prepare"],
708                    Some(operation(
709                        "POST",
710                        "/sonar/markets/{market}/execution/challenge",
711                        Some("strata_execution_challenge"),
712                    )),
713                ),
714                node(
715                    "sign_authorization",
716                    ActionNodeKind::ExternalSignature,
717                    "The agent owner's configured signer signs the returned authorization bytes externally.",
718                    &[],
719                    None,
720                ),
721                node(
722                    "prepare_execution",
723                    ActionNodeKind::Prepare,
724                    "Exchange the authorization signature for a quote-bound partially signed transaction.",
725                    &["trade.prepare"],
726                    Some(operation(
727                        "POST",
728                        "/sonar/markets/{market}/execution/prepare",
729                        Some("strata_execution_prepare"),
730                    )),
731                ),
732                node(
733                    "sign_transaction",
734                    ActionNodeKind::ExternalSignature,
735                    "The external signer verifies and fills its signature slot without sending key material to Strata.",
736                    &[],
737                    None,
738                ),
739                node(
740                    "submit_execution",
741                    ActionNodeKind::Submit,
742                    "Submit the signed transaction with an idempotency key.",
743                    &["trade.submit"],
744                    Some(operation(
745                        "POST",
746                        "/sonar/markets/{market}/execution/submit",
747                        Some("strata_execution_submit"),
748                    )),
749                ),
750                node(
751                    "receive_receipt",
752                    ActionNodeKind::Receipt,
753                    "Receive the execution ID, Solana signature, and submitted status.",
754                    &[],
755                    None,
756                ),
757                node(
758                    "request_order_challenge",
759                    ActionNodeKind::Prepare,
760                    "Bind a product-level place, cancel, bounded cancel-all, atomic replace, or atomic batch operation to canonical authorization bytes.",
761                    &["orders.prepare"],
762                    Some(operation(
763                        "POST",
764                        "/v2/markets/{market_id}/orders/challenge",
765                        Some("strata_order_challenge"),
766                    )),
767                ),
768                node(
769                    "sign_order_authorization",
770                    ActionNodeKind::ExternalSignature,
771                    "The agent owner's configured session signer verifies the exact order set and signs externally.",
772                    &[],
773                    None,
774                ),
775                node(
776                    "prepare_order_control",
777                    ActionNodeKind::Prepare,
778                    "Exchange the order authorization signature for a partially signed transaction.",
779                    &["orders.prepare"],
780                    Some(operation(
781                        "POST",
782                        "/v2/markets/{market_id}/orders/prepare",
783                        Some("strata_order_prepare"),
784                    )),
785                ),
786                node(
787                    "sign_order_transaction",
788                    ActionNodeKind::ExternalSignature,
789                    "The external session signer verifies and fills only its transaction signature slot.",
790                    &[],
791                    None,
792                ),
793                node(
794                    "submit_order_control",
795                    ActionNodeKind::Submit,
796                    "Submit the unchanged signed order transaction with an idempotency key.",
797                    &["orders.submit"],
798                    Some(operation(
799                        "POST",
800                        "/v2/markets/{market_id}/orders/submit",
801                        Some("strata_order_submit"),
802                    )),
803                ),
804                node(
805                    "receive_order_receipt",
806                    ActionNodeKind::Receipt,
807                    "Receive the opaque order IDs, transaction signature, and submitted status.",
808                    &[],
809                    None,
810                ),
811                node(
812                    "open_order_command_stream",
813                    ActionNodeKind::Prepare,
814                    "Authenticate one persistent sequenced order channel for low-latency commands, explicit self-trade policy, and pushed confirmation.",
815                    &["orders.prepare", "orders.submit"],
816                    Some(operation(
817                        "WEBSOCKET",
818                        "/v2/markets/{market_id}/orders/stream",
819                        None,
820                    )),
821                ),
822                node(
823                    "maintain_dead_man",
824                    ActionNodeKind::Submit,
825                    "Arm and heartbeat an exact pre-signed cancel-all that executes if the agent stops responding.",
826                    &["orders.prepare", "orders.submit"],
827                    Some(operation(
828                        "WEBSOCKET",
829                        "/v2/markets/{market_id}/orders/stream",
830                        None,
831                    )),
832                ),
833                node(
834                    "certify_order_command_slo",
835                    ActionNodeKind::Read,
836                    "Measure authenticated command latency, concurrency, sequence integrity, and error rate without submitting a trade.",
837                    &["orders.prepare", "orders.submit"],
838                    Some(operation(
839                        "WEBSOCKET",
840                        "/v2/markets/{market_id}/orders/stream",
841                        None,
842                    )),
843                ),
844                node(
845                    "recover_order_status",
846                    ActionNodeKind::Read,
847                    "Recover durable submitting, submitted, or failed status after a timeout or restart.",
848                    &["orders.submit"],
849                    Some(operation(
850                        "POST",
851                        "/v2/markets/{market_id}/orders/status",
852                        Some("strata_order_status"),
853                    )),
854                ),
855            ],
856            edges: vec![
857                edge("discover_capabilities", "discover_action_graph", "the returned contract version is supported"),
858                edge("discover_action_graph", "discover_markets", "markets.read is enabled"),
859                edge("discover_action_graph", "discover_platform_capabilities", "the versioned SDK contract is supported"),
860                edge("discover_platform_capabilities", "discover_platform_markets", "markets.read is enabled"),
861                edge("discover_platform_markets", "read_book", "books.read is enabled and the market is active"),
862                edge("discover_platform_markets", "read_market_status", "books.read is enabled"),
863                edge("discover_platform_markets", "read_best_bid_ask", "books.read is enabled"),
864                edge("discover_platform_markets", "read_fees", "books.read is enabled"),
865                edge("discover_platform_markets", "read_trades", "books.read is enabled"),
866                edge("read_book", "stream_market", "books.read is enabled and the snapshot sequence is accepted"),
867                edge("discover_platform_markets", "authorize_account_read", "account.read is enabled and the owner-configured signer is available"),
868                edge("authorize_account_read", "read_account", "the signature binds the wallet, market, request time, and fill limit"),
869                edge("read_account", "stream_account", "the stream challenge is signed by the same owner-configured signer"),
870                edge("discover_markets", "request_quote", "quotes.read is enabled and the market is ready"),
871                edge("request_quote", "request_execution_challenge", "trade.prepare is enabled and the quote is unexpired"),
872                edge("request_execution_challenge", "sign_authorization", "the challenge bindings match the quote and signer"),
873                edge("sign_authorization", "prepare_execution", "a valid external authorization signature is available"),
874                edge("prepare_execution", "sign_transaction", "the prepared transaction preserves the signed bindings"),
875                edge("sign_transaction", "submit_execution", "trade.submit is enabled and the signed transaction is unmodified"),
876                edge("submit_execution", "receive_receipt", "the execution ID and idempotency key match"),
877                edge("discover_platform_markets", "open_order_command_stream", "orders.prepare and orders.submit advertise websocket transport and the owner-configured session signer is available"),
878                edge("open_order_command_stream", "request_order_challenge", "signed socket authentication succeeds and an explicit self-trade prevention policy is selected"),
879                edge("open_order_command_stream", "maintain_dead_man", "the exact cancel-all authorization and transaction are externally verified and signed"),
880                edge("open_order_command_stream", "certify_order_command_slo", "a release or recurring production load certificate is required"),
881                edge("discover_platform_markets", "request_order_challenge", "orders.prepare is enabled and the market accepts order control"),
882                edge("request_order_challenge", "sign_order_authorization", "the action and exact opaque order set match owner intent"),
883                edge("sign_order_authorization", "prepare_order_control", "a valid external authorization signature is available"),
884                edge("prepare_order_control", "sign_order_transaction", "the prepared transaction preserves the signed order bindings"),
885                edge("sign_order_transaction", "submit_order_control", "orders.submit is enabled and the signed transaction is unmodified"),
886                edge("submit_order_control", "receive_order_receipt", "the control ID and idempotency key match"),
887                edge("submit_order_control", "recover_order_status", "the submission result is ambiguous or either process restarted"),
888                edge("submit_order_control", "maintain_dead_man", "the agent has resting exposure that must fail closed on disconnect"),
889                edge("recover_order_status", "receive_order_receipt", "durable status is submitted"),
890            ],
891        }
892    }
893}
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898
899    #[test]
900    fn public_quote_field_set_is_sealed() {
901        let quote: QuoteResponse = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
902        let value = serde_json::to_value(quote).unwrap();
903        let object = value.as_object().unwrap();
904        let mut actual = object.keys().map(String::as_str).collect::<Vec<_>>();
905        actual.sort_unstable();
906        let mut expected = vec![
907            "amount_in_atoms",
908            "amount_in_consumed_atoms",
909            "amount_out_atoms",
910            "contract_version",
911            "expires_at_ms",
912            "input_fee_atoms",
913            "market_id",
914            "maximum_tolerance_bps",
915            "minimum_output_atoms",
916            "output_fee_atoms",
917            "price_impact_pct",
918            "provider",
919            "quote_id",
920            "reference_price",
921            "schema_version",
922            "server_time_ms",
923            "side",
924        ];
925        expected.sort_unstable();
926        assert_eq!(actual, expected, "public quote fields must remain sealed");
927        assert_eq!(object["amount_out_atoms"], "1990000");
928        assert_eq!(object["minimum_output_atoms"], "1980050");
929        assert_eq!(object["maximum_tolerance_bps"], 50);
930        assert_eq!(object["provider"], "Sonar");
931
932        // The request field is `maximum_tolerance_bps`; the legacy spelling
933        // still deserializes so older clients keep working.
934        let legacy: QuoteRequest = serde_json::from_value(serde_json::json!({
935            "market_id": "11111111111111111111111111111111",
936            "side": "sell",
937            "amount_in_atoms": "10000000",
938            "slippage_bps": 25
939        }))
940        .unwrap();
941        assert_eq!(legacy.maximum_tolerance_bps, 25);
942        assert!(serde_json::to_string(&legacy)
943            .unwrap()
944            .contains("\"maximum_tolerance_bps\":25"));
945    }
946
947    #[test]
948    fn reviewed_action_capabilities_are_public_and_typed() {
949        let catalog = CapabilityCatalog::foundation();
950        let prepare = catalog
951            .capabilities
952            .iter()
953            .find(|item| item.id == "trade.prepare")
954            .unwrap();
955        let submit = catalog
956            .capabilities
957            .iter()
958            .find(|item| item.id == "trade.submit")
959            .unwrap();
960        assert!(prepare.default_enabled && prepare.public_sdk);
961        assert_eq!(prepare.risk, CapabilityRisk::Prepare);
962        assert_eq!(prepare.mcp_exposure, McpExposure::Prepare);
963        assert!(submit.default_enabled && submit.public_sdk);
964        assert_eq!(submit.risk, CapabilityRisk::Submit);
965        assert_eq!(submit.mcp_exposure, McpExposure::Submit);
966    }
967
968    #[test]
969    fn shared_v1_fixtures_decode_strictly() {
970        let quote: QuoteResponse = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
971        let markets: MarketsResponse = serde_json::from_str(contract_fixtures::MARKETS).unwrap();
972        let capabilities: CapabilityCatalog =
973            serde_json::from_str(contract_fixtures::CAPABILITIES).unwrap();
974        let action_graph: ActionGraph =
975            serde_json::from_str(contract_fixtures::ACTION_GRAPH).unwrap();
976
977        assert_eq!(quote.contract_version, CONTRACT_VERSION);
978        assert_eq!(markets.contract_version, CONTRACT_VERSION);
979        assert_eq!(capabilities, CapabilityCatalog::foundation());
980        assert_eq!(action_graph, ActionGraph::for_catalog(&capabilities));
981    }
982
983    #[test]
984    fn strict_contract_rejects_unreviewed_quote_fields() {
985        let mut value: serde_json::Value = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
986        value
987            .as_object_mut()
988            .unwrap()
989            .insert("unexpected_field".to_owned(), serde_json::json!("hidden"));
990
991        assert!(serde_json::from_value::<QuoteResponse>(value).is_err());
992    }
993
994    #[test]
995    fn execution_contract_exposes_only_minimum_output_protection() {
996        let challenge: ExecutionChallengeResponse =
997            serde_json::from_str(contract_fixtures::EXECUTION_CHALLENGE).unwrap();
998        let prepared: ExecutionPrepareResponse =
999            serde_json::from_str(contract_fixtures::EXECUTION_PREPARE).unwrap();
1000        let submitted: ExecutionSubmitResponse =
1001            serde_json::from_str(contract_fixtures::EXECUTION_SUBMIT).unwrap();
1002
1003        assert_eq!(
1004            challenge.minimum_output_atoms,
1005            prepared.minimum_output_atoms
1006        );
1007        assert_eq!(challenge.quote_id, prepared.quote_id);
1008        assert_eq!(challenge.market_id, prepared.market_id);
1009        assert_eq!(submitted.execution_id, prepared.execution_id);
1010
1011        for fixture in [
1012            contract_fixtures::EXECUTION_CHALLENGE,
1013            contract_fixtures::EXECUTION_PREPARE,
1014            contract_fixtures::EXECUTION_SUBMIT,
1015        ] {
1016            let value: serde_json::Value = serde_json::from_str(fixture).unwrap();
1017            let keys = value.as_object().unwrap().keys().collect::<Vec<_>>();
1018            for forbidden in [
1019                "route",
1020                "venue",
1021                "layer",
1022                "plan",
1023                "collar",
1024                "limit_price",
1025                "internal",
1026                "l3",
1027                "footprint",
1028            ] {
1029                assert!(
1030                    keys.iter().all(|key| !key.contains(forbidden)),
1031                    "execution contract exposed forbidden field containing {forbidden}"
1032                );
1033            }
1034        }
1035    }
1036}