1use serde::{Deserialize, Serialize};
9
10pub mod platform;
11
12pub const CONTRACT_MAJOR: u16 = 1;
13pub const CONTRACT_VERSION: &str = "1.1";
14pub const DEFAULT_SLIPPAGE_BPS: u16 = 0;
16
17#[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 pub permission_source: String,
51 pub signing_location: String,
53 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 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 pub amount_in_atoms: String,
114 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 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 pub amount_in_consumed_atoms: String,
134 pub amount_out_atoms: String,
137 pub minimum_output_atoms: String,
139 pub input_fee_atoms: String,
142 pub output_fee_atoms: String,
145 pub reference_price: String,
148 pub price_impact_pct: String,
149 pub provider: String,
150}
151
152#[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 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 pub minimum_output_atoms: String,
178 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 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 pub minimum_output_atoms: String,
205 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 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 pub ready: bool,
250 pub base_decimals: u8,
251 pub quote_decimals: u8,
252 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 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}