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_MAXIMUM_TOLERANCE_BPS: u16 = 0;
17pub const DEFAULT_SLIPPAGE_BPS: u16 = DEFAULT_MAXIMUM_TOLERANCE_BPS;
19
20#[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 pub permission_source: String,
54 pub signing_location: String,
56 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 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 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub amount_in_atoms: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub amount_out_atoms: Option<String>,
127 #[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 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 pub amount_in_consumed_atoms: String,
151 pub amount_out_atoms: String,
154 pub minimum_output_atoms: String,
158 pub input_fee_atoms: String,
161 pub output_fee_atoms: String,
164 pub maximum_tolerance_bps: u16,
168 pub reference_price: String,
174 pub price_impact_pct: String,
175 pub provider: String,
176}
177
178#[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 #[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 pub minimum_output_atoms: String,
207 pub authorization_payload_base64: String,
209 pub server_time_ms: u64,
210 pub expires_at_ms: u64,
211}
212
213#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
215#[serde(deny_unknown_fields)]
216pub struct ExecutionPrepareAuthorization {
217 pub challenge_id: String,
218 pub authorization_signature: String,
220}
221
222#[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 pub minimum_output_atoms: String,
246 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 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 pub ready: bool,
291 pub base_decimals: u8,
292 pub quote_decimals: u8,
293 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 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 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}