1use serde::{Deserialize, Serialize};
9
10pub const CONTRACT_MAJOR: u16 = 1;
11pub const CONTRACT_VERSION: &str = "1.1";
12pub const DEFAULT_SLIPPAGE_BPS: u16 = 0;
14
15#[cfg(any(test, feature = "fixtures"))]
20#[doc(hidden)]
21pub mod contract_fixtures {
22 pub const CAPABILITIES: &str = include_str!("../fixtures/v1/capabilities.json");
23 pub const EXECUTION_CHALLENGE: &str = include_str!("../fixtures/v1/execution-challenge.json");
24 pub const EXECUTION_PREPARE: &str = include_str!("../fixtures/v1/execution-prepare.json");
25 pub const EXECUTION_SUBMIT: &str = include_str!("../fixtures/v1/execution-submit.json");
26 pub const MARKETS: &str = include_str!("../fixtures/v1/markets.json");
27 pub const QUOTE: &str = include_str!("../fixtures/v1/quote.json");
28}
29
30#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
31#[serde(rename_all = "snake_case")]
32pub enum QuoteSide {
33 Buy,
34 Sell,
35}
36
37#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
38#[serde(deny_unknown_fields)]
39pub struct QuoteRequest {
40 pub market_id: String,
41 pub side: QuoteSide,
42 pub amount_in_atoms: String,
45 pub slippage_bps: u16,
48}
49
50#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
51#[serde(deny_unknown_fields)]
52pub struct QuoteResponse {
53 pub schema_version: u16,
54 pub contract_version: String,
55 pub quote_id: String,
58 pub server_time_ms: u64,
59 pub expires_at_ms: u64,
60 pub market_id: String,
61 pub side: QuoteSide,
62 pub amount_in_atoms: String,
63 pub amount_in_consumed_atoms: String,
65 pub amount_out_atoms: String,
66 pub minimum_output_atoms: String,
67 pub input_fee_atoms: String,
70 pub output_fee_atoms: String,
72 pub reference_price: String,
75 pub price_impact_pct: String,
76 pub provider: String,
77}
78
79#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
83#[serde(deny_unknown_fields)]
84pub struct ExecutionChallengeRequest {
85 pub quote_id: String,
86 pub owner_wallet: String,
87 pub session_public_key: String,
88 pub account_sequence: String,
91}
92
93#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
94#[serde(deny_unknown_fields)]
95pub struct ExecutionChallengeResponse {
96 pub schema_version: u16,
97 pub contract_version: String,
98 pub challenge_id: String,
99 pub quote_id: String,
100 pub market_id: String,
101 pub side: QuoteSide,
102 pub amount_in_atoms: String,
103 pub minimum_output_atoms: String,
105 pub authorization_payload_base64: String,
107 pub server_time_ms: u64,
108 pub expires_at_ms: u64,
109}
110
111#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
112#[serde(deny_unknown_fields)]
113pub struct ExecutionPrepareRequest {
114 pub challenge_id: String,
115 pub authorization_signature: String,
117}
118
119#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
120#[serde(deny_unknown_fields)]
121pub struct ExecutionPrepareResponse {
122 pub schema_version: u16,
123 pub contract_version: String,
124 pub execution_id: String,
125 pub quote_id: String,
126 pub market_id: String,
127 pub side: QuoteSide,
128 pub amount_in_atoms: String,
129 pub minimum_output_atoms: String,
132 pub transaction_base64: String,
135 pub recent_blockhash: String,
136 pub last_valid_block_height: u64,
137 pub expires_at_ms: u64,
138}
139
140#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
141#[serde(deny_unknown_fields)]
142pub struct ExecutionSubmitRequest {
143 pub execution_id: String,
144 pub signed_transaction_base64: String,
145 pub idempotency_key: String,
148}
149
150#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
151#[serde(deny_unknown_fields)]
152pub struct ExecutionSubmitResponse {
153 pub schema_version: u16,
154 pub contract_version: String,
155 pub execution_id: String,
156 pub signature: String,
157 pub status: ExecutionStatus,
158}
159
160#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
161#[serde(rename_all = "snake_case")]
162pub enum ExecutionStatus {
163 Submitted,
164}
165
166#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
167#[serde(deny_unknown_fields)]
168pub struct Market {
169 pub base: String,
170 pub quote: String,
171 pub market_pda: Option<String>,
172 pub label: String,
173 pub ready: bool,
177 pub base_decimals: u8,
178 pub quote_decimals: u8,
179 pub quote_path: Option<String>,
182}
183
184#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
185#[serde(deny_unknown_fields)]
186pub struct MarketsResponse {
187 pub schema_version: u16,
188 pub contract_version: String,
189 pub markets: Vec<Market>,
190}
191
192impl MarketsResponse {
193 pub fn new(markets: Vec<Market>) -> Self {
194 Self {
195 schema_version: CONTRACT_MAJOR,
196 contract_version: CONTRACT_VERSION.to_owned(),
197 markets,
198 }
199 }
200}
201
202#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
203#[serde(deny_unknown_fields)]
204pub struct ErrorDetail {
205 pub code: String,
206 pub message: String,
207 pub retryable: bool,
208}
209
210#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
211#[serde(deny_unknown_fields)]
212pub struct ErrorResponse {
213 pub schema_version: u16,
214 pub contract_version: String,
215 pub error: ErrorDetail,
216}
217
218impl ErrorResponse {
219 pub fn new(code: impl Into<String>, message: impl Into<String>, retryable: bool) -> Self {
220 Self {
221 schema_version: CONTRACT_MAJOR,
222 contract_version: CONTRACT_VERSION.to_owned(),
223 error: ErrorDetail {
224 code: code.into(),
225 message: message.into(),
226 retryable,
227 },
228 }
229 }
230}
231
232#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
233#[serde(rename_all = "snake_case")]
234pub enum CapabilityStability {
235 Internal,
236 Beta,
237 Stable,
238}
239
240#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
241#[serde(rename_all = "snake_case")]
242pub enum CapabilityRisk {
243 Read,
244 Prepare,
245 Submit,
246 Destructive,
247}
248
249#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
250#[serde(rename_all = "snake_case")]
251pub enum McpExposure {
252 None,
253 Read,
254 Prepare,
255 Submit,
256}
257
258#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
259#[serde(deny_unknown_fields)]
260pub struct CapabilityDescriptor {
261 pub id: String,
262 pub introduced_in: String,
263 pub stability: CapabilityStability,
264 pub required_scope: String,
265 pub risk: CapabilityRisk,
266 pub default_enabled: bool,
267 pub public_sdk: bool,
268 pub mcp_exposure: McpExposure,
269}
270
271#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
272#[serde(deny_unknown_fields)]
273pub struct CapabilityCatalog {
274 pub schema_version: u16,
275 pub contract_version: String,
276 pub capabilities: Vec<CapabilityDescriptor>,
277}
278
279impl CapabilityCatalog {
280 pub fn foundation() -> Self {
281 use CapabilityRisk::{Prepare, Read, Submit};
282 use CapabilityStability::{Beta, Stable};
283 use McpExposure::{None as McpNone, Read as McpRead};
284
285 let capability = |id: &str,
286 introduced_in: &str,
287 stability,
288 scope: &str,
289 risk,
290 default_enabled,
291 public_sdk,
292 mcp_exposure| {
293 CapabilityDescriptor {
294 id: id.to_owned(),
295 introduced_in: introduced_in.to_owned(),
296 stability,
297 required_scope: scope.to_owned(),
298 risk,
299 default_enabled,
300 public_sdk,
301 mcp_exposure,
302 }
303 };
304
305 Self {
306 schema_version: CONTRACT_MAJOR,
307 contract_version: CONTRACT_VERSION.to_owned(),
308 capabilities: vec![
309 capability(
310 "markets.read",
311 "1.0",
312 Stable,
313 "market:read",
314 Read,
315 true,
316 true,
317 McpRead,
318 ),
319 capability(
320 "books.read",
321 "1.0",
322 Beta,
323 "market:read",
324 Read,
325 false,
326 false,
327 McpNone,
328 ),
329 capability(
330 "quotes.read",
331 "1.0",
332 Beta,
333 "market:read",
334 Read,
335 true,
336 true,
337 McpRead,
338 ),
339 capability(
340 "account.read",
341 "1.0",
342 Beta,
343 "account:read",
344 Read,
345 false,
346 false,
347 McpNone,
348 ),
349 capability(
350 "trade.prepare",
351 "1.1",
352 Beta,
353 "trade:prepare",
354 Prepare,
355 false,
356 true,
357 McpNone,
358 ),
359 capability(
360 "trade.submit",
361 "1.1",
362 Beta,
363 "trade:submit",
364 Submit,
365 false,
366 true,
367 McpNone,
368 ),
369 ],
370 }
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn public_quote_field_set_is_sealed() {
380 let quote: QuoteResponse = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
381 let value = serde_json::to_value(quote).unwrap();
382 let object = value.as_object().unwrap();
383 let mut actual = object.keys().map(String::as_str).collect::<Vec<_>>();
384 actual.sort_unstable();
385 let mut expected = vec![
386 "amount_in_atoms",
387 "amount_in_consumed_atoms",
388 "amount_out_atoms",
389 "contract_version",
390 "expires_at_ms",
391 "input_fee_atoms",
392 "market_id",
393 "minimum_output_atoms",
394 "output_fee_atoms",
395 "price_impact_pct",
396 "provider",
397 "quote_id",
398 "reference_price",
399 "schema_version",
400 "server_time_ms",
401 "side",
402 ];
403 expected.sort_unstable();
404 assert_eq!(actual, expected, "public quote fields must remain sealed");
405 assert_eq!(object["amount_out_atoms"], "1990000");
406 assert_eq!(object["minimum_output_atoms"], "1980050");
407 assert_eq!(object["provider"], "Sonar");
408 }
409
410 #[test]
411 fn new_capabilities_are_safe_by_default() {
412 let catalog = CapabilityCatalog::foundation();
413 for capability in catalog.capabilities {
414 if capability.risk != CapabilityRisk::Read {
415 assert!(!capability.default_enabled);
416 }
417 }
418 }
419
420 #[test]
421 fn shared_v1_fixtures_decode_strictly() {
422 let quote: QuoteResponse = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
423 let markets: MarketsResponse = serde_json::from_str(contract_fixtures::MARKETS).unwrap();
424 let capabilities: CapabilityCatalog =
425 serde_json::from_str(contract_fixtures::CAPABILITIES).unwrap();
426
427 assert_eq!(quote.contract_version, CONTRACT_VERSION);
428 assert_eq!(markets.contract_version, CONTRACT_VERSION);
429 assert_eq!(capabilities, CapabilityCatalog::foundation());
430 }
431
432 #[test]
433 fn strict_contract_rejects_unreviewed_quote_fields() {
434 let mut value: serde_json::Value = serde_json::from_str(contract_fixtures::QUOTE).unwrap();
435 value
436 .as_object_mut()
437 .unwrap()
438 .insert("unexpected_field".to_owned(), serde_json::json!("hidden"));
439
440 assert!(serde_json::from_value::<QuoteResponse>(value).is_err());
441 }
442
443 #[test]
444 fn execution_contract_exposes_only_minimum_output_protection() {
445 let challenge: ExecutionChallengeResponse =
446 serde_json::from_str(contract_fixtures::EXECUTION_CHALLENGE).unwrap();
447 let prepared: ExecutionPrepareResponse =
448 serde_json::from_str(contract_fixtures::EXECUTION_PREPARE).unwrap();
449 let submitted: ExecutionSubmitResponse =
450 serde_json::from_str(contract_fixtures::EXECUTION_SUBMIT).unwrap();
451
452 assert_eq!(
453 challenge.minimum_output_atoms,
454 prepared.minimum_output_atoms
455 );
456 assert_eq!(challenge.quote_id, prepared.quote_id);
457 assert_eq!(challenge.market_id, prepared.market_id);
458 assert_eq!(submitted.execution_id, prepared.execution_id);
459
460 for fixture in [
461 contract_fixtures::EXECUTION_CHALLENGE,
462 contract_fixtures::EXECUTION_PREPARE,
463 contract_fixtures::EXECUTION_SUBMIT,
464 ] {
465 let value: serde_json::Value = serde_json::from_str(fixture).unwrap();
466 let keys = value.as_object().unwrap().keys().collect::<Vec<_>>();
467 for forbidden in [
468 "route",
469 "venue",
470 "layer",
471 "plan",
472 "collar",
473 "limit_price",
474 "internal",
475 "l3",
476 "footprint",
477 ] {
478 assert!(
479 keys.iter().all(|key| !key.contains(forbidden)),
480 "execution contract exposed forbidden field containing {forbidden}"
481 );
482 }
483 }
484 }
485}