1use serde::{Deserialize, Serialize};
9
10pub const CONTRACT_MAJOR: u16 = 1;
11pub const CONTRACT_VERSION: &str = "1.0";
12
13#[cfg(any(test, feature = "fixtures"))]
18#[doc(hidden)]
19pub mod contract_fixtures {
20 pub const CAPABILITIES: &str = include_str!("../fixtures/v1/capabilities.json");
21 pub const MARKETS: &str = include_str!("../fixtures/v1/markets.json");
22 pub const QUOTE: &str = include_str!("../fixtures/v1/quote.json");
23}
24
25#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
26#[serde(rename_all = "snake_case")]
27pub enum QuoteSide {
28 Buy,
29 Sell,
30}
31
32#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
33#[serde(deny_unknown_fields)]
34pub struct QuoteRequest {
35 pub market_id: String,
36 pub side: QuoteSide,
37 pub amount_in_atoms: String,
40 pub slippage_bps: u16,
41}
42
43#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[serde(deny_unknown_fields)]
45pub struct QuoteResponse {
46 pub schema_version: u16,
47 pub contract_version: String,
48 pub quote_id: String,
51 pub server_time_ms: u64,
52 pub expires_at_ms: u64,
53 pub market_id: String,
54 pub side: QuoteSide,
55 pub amount_in_atoms: String,
56 pub amount_in_consumed_atoms: String,
58 pub amount_out_atoms: String,
59 pub minimum_output_atoms: String,
60 pub input_fee_atoms: String,
63 pub output_fee_atoms: String,
65 pub reference_price: String,
68 pub price_impact_pct: String,
69 pub provider: String,
70}
71
72#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
73#[serde(deny_unknown_fields)]
74pub struct Market {
75 pub base: String,
76 pub quote: String,
77 pub market_pda: Option<String>,
78 pub label: String,
79 pub ready: bool,
83 pub base_decimals: u8,
84 pub quote_decimals: u8,
85 pub quote_path: Option<String>,
88}
89
90#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
91#[serde(deny_unknown_fields)]
92pub struct MarketsResponse {
93 pub schema_version: u16,
94 pub contract_version: String,
95 pub markets: Vec<Market>,
96}
97
98impl MarketsResponse {
99 pub fn new(markets: Vec<Market>) -> Self {
100 Self {
101 schema_version: CONTRACT_MAJOR,
102 contract_version: CONTRACT_VERSION.to_owned(),
103 markets,
104 }
105 }
106}
107
108#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
109#[serde(deny_unknown_fields)]
110pub struct ErrorDetail {
111 pub code: String,
112 pub message: String,
113 pub retryable: bool,
114}
115
116#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
117#[serde(deny_unknown_fields)]
118pub struct ErrorResponse {
119 pub schema_version: u16,
120 pub contract_version: String,
121 pub error: ErrorDetail,
122}
123
124impl ErrorResponse {
125 pub fn new(code: impl Into<String>, message: impl Into<String>, retryable: bool) -> Self {
126 Self {
127 schema_version: CONTRACT_MAJOR,
128 contract_version: CONTRACT_VERSION.to_owned(),
129 error: ErrorDetail {
130 code: code.into(),
131 message: message.into(),
132 retryable,
133 },
134 }
135 }
136}
137
138#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
139#[serde(rename_all = "snake_case")]
140pub enum CapabilityStability {
141 Internal,
142 Beta,
143 Stable,
144}
145
146#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
147#[serde(rename_all = "snake_case")]
148pub enum CapabilityRisk {
149 Read,
150 Prepare,
151 Submit,
152 Destructive,
153}
154
155#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
156#[serde(rename_all = "snake_case")]
157pub enum McpExposure {
158 None,
159 Read,
160 Prepare,
161 Submit,
162}
163
164#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
165#[serde(deny_unknown_fields)]
166pub struct CapabilityDescriptor {
167 pub id: String,
168 pub introduced_in: String,
169 pub stability: CapabilityStability,
170 pub required_scope: String,
171 pub risk: CapabilityRisk,
172 pub default_enabled: bool,
173 pub public_sdk: bool,
174 pub mcp_exposure: McpExposure,
175}
176
177#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
178#[serde(deny_unknown_fields)]
179pub struct CapabilityCatalog {
180 pub schema_version: u16,
181 pub contract_version: String,
182 pub capabilities: Vec<CapabilityDescriptor>,
183}
184
185impl CapabilityCatalog {
186 pub fn foundation() -> Self {
187 use CapabilityRisk::{Prepare, Read, Submit};
188 use CapabilityStability::{Beta, Stable};
189 use McpExposure::{None as McpNone, Read as McpRead};
190
191 let capability =
192 |id: &str, stability, scope: &str, risk, default_enabled, public_sdk, mcp_exposure| {
193 CapabilityDescriptor {
194 id: id.to_owned(),
195 introduced_in: CONTRACT_VERSION.to_owned(),
196 stability,
197 required_scope: scope.to_owned(),
198 risk,
199 default_enabled,
200 public_sdk,
201 mcp_exposure,
202 }
203 };
204
205 Self {
206 schema_version: CONTRACT_MAJOR,
207 contract_version: CONTRACT_VERSION.to_owned(),
208 capabilities: vec![
209 capability(
210 "markets.read",
211 Stable,
212 "market:read",
213 Read,
214 true,
215 true,
216 McpRead,
217 ),
218 capability(
219 "books.read",
220 Beta,
221 "market:read",
222 Read,
223 false,
224 false,
225 McpNone,
226 ),
227 capability(
228 "quotes.read",
229 Beta,
230 "market:read",
231 Read,
232 true,
233 true,
234 McpRead,
235 ),
236 capability(
237 "account.read",
238 Beta,
239 "account:read",
240 Read,
241 false,
242 false,
243 McpNone,
244 ),
245 capability(
246 "trade.prepare",
247 Beta,
248 "trade:prepare",
249 Prepare,
250 false,
251 false,
252 McpNone,
253 ),
254 capability(
255 "trade.submit",
256 Beta,
257 "trade:submit",
258 Submit,
259 false,
260 false,
261 McpNone,
262 ),
263 ],
264 }
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 #[test]
273 fn public_quote_field_set_is_sealed() {
274 let quote: QuoteResponse = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
275 let value = serde_json::to_value(quote).unwrap();
276 let object = value.as_object().unwrap();
277 let mut actual = object.keys().map(String::as_str).collect::<Vec<_>>();
278 actual.sort_unstable();
279 let mut expected = vec![
280 "amount_in_atoms",
281 "amount_in_consumed_atoms",
282 "amount_out_atoms",
283 "contract_version",
284 "expires_at_ms",
285 "input_fee_atoms",
286 "market_id",
287 "minimum_output_atoms",
288 "output_fee_atoms",
289 "price_impact_pct",
290 "provider",
291 "quote_id",
292 "reference_price",
293 "schema_version",
294 "server_time_ms",
295 "side",
296 ];
297 expected.sort_unstable();
298 assert_eq!(actual, expected, "public quote fields must remain sealed");
299 assert_eq!(object["amount_out_atoms"], "1990000");
300 assert_eq!(object["minimum_output_atoms"], "1980050");
301 assert_eq!(object["provider"], "Sonar");
302 }
303
304 #[test]
305 fn new_capabilities_are_safe_by_default() {
306 let catalog = CapabilityCatalog::foundation();
307 for capability in catalog.capabilities {
308 if capability.risk != CapabilityRisk::Read {
309 assert!(!capability.default_enabled);
310 }
311 }
312 }
313
314 #[test]
315 fn shared_v1_fixtures_decode_strictly() {
316 let quote: QuoteResponse = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
317 let markets: MarketsResponse = serde_json::from_str(contract_fixtures::MARKETS).unwrap();
318 let capabilities: CapabilityCatalog =
319 serde_json::from_str(contract_fixtures::CAPABILITIES).unwrap();
320
321 assert_eq!(quote.contract_version, CONTRACT_VERSION);
322 assert_eq!(markets.contract_version, CONTRACT_VERSION);
323 assert_eq!(capabilities, CapabilityCatalog::foundation());
324 }
325
326 #[test]
327 fn strict_contract_rejects_unreviewed_quote_fields() {
328 let mut value: serde_json::Value = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
329 value
330 .as_object_mut()
331 .unwrap()
332 .insert("unexpected_field".to_owned(), serde_json::json!("hidden"));
333
334 assert!(serde_json::from_value::<QuoteResponse>(value).is_err());
335 }
336}