1use crate::clock::ServerClock;
13use crate::credentials::Credentials;
14use crate::error::{Error, Result};
15use crate::events::{BookDelta, BookLevel, Event, OrderBookSnapshot, TradePrint};
16use crate::idempotency::ClientIdGenerator;
17use crate::normalize::{format_decimal, parse_decimal};
18use crate::options::{ExchangeOptions, MarketType, SelfTradePrevention};
19use crate::symbol::Symbol;
20use crate::traits::{Exchange, Execution, MarketData};
21use crate::transport::{
22 HttpMethod, HttpRequest, HttpResponse, HttpTransport, WsConnection, WsTransport,
23};
24use crate::types::{
25 Balance, Order, OrderRequest, OrderSide, OrderStatus, OrderType, Ticker, TimeInForce,
26};
27use base64::Engine;
28use p256::ecdsa::{signature::Signer, Signature, SigningKey};
29use p256::pkcs8::DecodePrivateKey;
30use rust_decimal::Decimal;
31use std::fmt;
32use wickra_core::Candle;
33
34const HOST: &str = "api.coinbase.com";
35
36fn system_now_ms() -> i64 {
37 use std::time::{SystemTime, UNIX_EPOCH};
38 SystemTime::now()
39 .duration_since(UNIX_EPOCH)
40 .expect("system clock is before the Unix epoch")
41 .as_millis() as i64
42}
43
44fn b64url(data: &[u8]) -> String {
45 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
46}
47
48pub struct Coinbase {
50 http: Box<dyn HttpTransport>,
51 ws: Option<Box<dyn WsTransport>>,
52 rest_base: String,
53 market_type: MarketType,
59 credentials: Option<Credentials>,
60 now_ms: Box<dyn Fn() -> i64 + Send + Sync>,
61 client_ids: ClientIdGenerator,
70 clock: ServerClock,
73 connection: Option<Box<dyn WsConnection>>,
74 sub_messages: Vec<String>,
75}
76
77impl fmt::Debug for Coinbase {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 f.debug_struct("Coinbase")
85 .field("ws", &self.ws.is_some())
86 .field("rest_base", &self.rest_base)
87 .field("authenticated", &self.credentials.is_some())
88 .field("clock_offset_ms", &self.clock.offset_ms())
89 .field("connection", &self.connection.is_some())
90 .field("sub_messages", &self.sub_messages.len())
91 .finish_non_exhaustive()
92 }
93}
94
95impl Coinbase {
96 fn ensure_market_is_routed(&self) -> Result<()> {
104 super::ensure_market_is_routed("Coinbase", self.market_type, super::SPOT_ONLY)
105 }
106
107 fn build(
108 http: Box<dyn HttpTransport>,
109 options: &ExchangeOptions,
110 credentials: Option<Credentials>,
111 ) -> Self {
112 Self {
113 http,
114 ws: None,
115 rest_base: format!("https://{HOST}"),
116 market_type: options.market_type,
117 credentials,
118 now_ms: Box::new(system_now_ms),
119 client_ids: ClientIdGenerator::with_seed(
120 "wkex",
121 u64::try_from(system_now_ms()).unwrap_or(0),
122 ),
123 clock: ServerClock::new(),
124 connection: None,
125 sub_messages: Vec::new(),
126 }
127 }
128
129 fn signed_now_ms(&self) -> i64 {
138 self.clock.server_time_ms((self.now_ms)())
139 }
140
141 pub fn sync_time(&mut self) -> Result<i64> {
153 self.ensure_market_is_routed()?;
154 let local_before = (self.now_ms)();
157 let url = format!("{}/api/v3/brokerage/time", self.rest_base);
158 let response = self.http.execute(&HttpRequest::get(url))?;
159 let value = parse_body(&response)?;
160 let server_ms: i64 = value
161 .get("epochMillis")
162 .and_then(serde_json::Value::as_str)
163 .ok_or_else(|| Error::Deserialization("brokerage/time: no epochMillis".into()))?
164 .parse()
165 .map_err(|_| {
166 Error::Deserialization("brokerage/time: epochMillis not an integer".into())
167 })?;
168 self.clock.sync(local_before, server_ms);
169 Ok(self.clock.offset_ms())
170 }
171
172 #[must_use]
176 pub fn with_http(http: Box<dyn HttpTransport>, options: &ExchangeOptions) -> Self {
177 Self::build(http, options, None)
178 }
179
180 #[must_use]
182 pub fn with_credentials(
183 http: Box<dyn HttpTransport>,
184 options: &ExchangeOptions,
185 credentials: Credentials,
186 ) -> Self {
187 Self::build(http, options, Some(credentials))
188 }
189
190 #[must_use]
192 pub fn with_clock(mut self, now_ms: Box<dyn Fn() -> i64 + Send + Sync>) -> Self {
193 self.now_ms = now_ms;
194 self
195 }
196
197 #[must_use]
199 pub fn with_ws(mut self, ws: Box<dyn WsTransport>) -> Self {
200 self.ws = Some(ws);
201 self
202 }
203
204 #[must_use]
206 pub fn wire_symbol(symbol: &Symbol) -> String {
207 format!("{}-{}", symbol.base(), symbol.quote())
208 }
209
210 pub fn ticker(&self, symbol: &Symbol) -> Result<Ticker> {
215 let path = format!(
216 "/api/v3/brokerage/products/{}/ticker",
217 Self::wire_symbol(symbol)
218 );
219 let value = self.signed_get(&path, "limit=1")?;
220 let last = value
221 .get("trades")
222 .and_then(serde_json::Value::as_array)
223 .and_then(|t| t.first())
224 .and_then(|t| t.get("price"))
225 .and_then(serde_json::Value::as_str)
226 .ok_or_else(|| Error::Deserialization("missing last price".to_string()))?;
227 Ok(Ticker {
228 symbol: symbol.clone(),
229 last: parse_decimal(last)?,
230 bid: parse_decimal(str_field(&value, "best_bid")?)?,
231 ask: parse_decimal(str_field(&value, "best_ask")?)?,
232 volume: Decimal::ZERO,
233 timestamp: 0,
235 })
236 }
237
238 pub fn klines(&self, symbol: &Symbol, interval: &str, limit: u32) -> Result<Vec<Candle>> {
244 let (granularity, step) = granularity(interval);
245 let end = (self.now_ms)() / 1000;
246 let start = end - i64::from(limit) * step;
247 let path = format!(
248 "/api/v3/brokerage/products/{}/candles",
249 Self::wire_symbol(symbol)
250 );
251 let query = format!("granularity={granularity}&start={start}&end={end}");
252 let value = self.signed_get(&path, &query)?;
253 let candles = value
254 .get("candles")
255 .and_then(serde_json::Value::as_array)
256 .ok_or_else(|| Error::Deserialization("missing candles".to_string()))?;
257 let mut parsed = candles
258 .iter()
259 .map(parse_candle)
260 .collect::<Result<Vec<_>>>()?;
261 parsed.reverse();
262 Ok(parsed)
263 }
264
265 pub fn order_book(&self, symbol: &Symbol, depth: u32) -> Result<OrderBookSnapshot> {
270 let query = format!("product_id={}&limit={depth}", Self::wire_symbol(symbol));
271 let value = self.signed_get("/api/v3/brokerage/product_book", &query)?;
272 let book = value
273 .get("pricebook")
274 .ok_or_else(|| Error::Deserialization("missing pricebook".to_string()))?;
275 Ok(OrderBookSnapshot {
276 symbol: symbol.clone(),
277 last_update_id: 0,
278 bids: object_levels(book.get("bids"))?,
279 asks: object_levels(book.get("asks"))?,
280 timestamp: 0,
281 })
282 }
283
284 pub fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
290 self.subscribe(symbol, "market_trades")
291 }
292
293 pub fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
298 self.subscribe(symbol, "level2")
299 }
300
301 pub fn subscribe_ticker(&mut self, symbol: &Symbol) -> Result<()> {
306 self.subscribe(symbol, "ticker")
307 }
308
309 fn subscribe(&mut self, symbol: &Symbol, channel: &str) -> Result<()> {
310 self.ensure_market_is_routed()?;
311 let jwt = self.build_jwt(None)?;
312 let product = Self::wire_symbol(symbol);
313 if self.connection.is_none() {
314 let ws = self.ws.as_ref().ok_or(Error::NotConnected)?;
315 let connection = ws.connect("wss://advanced-trade-ws.coinbase.com")?;
316 self.connection = Some(connection);
317 }
318 let message = format!(
319 r#"{{"type":"subscribe","product_ids":["{product}"],"channel":"{channel}","jwt":"{jwt}"}}"#
320 );
321 self.connection
322 .as_mut()
323 .expect("connection just ensured")
324 .send(&message)?;
325 if !self.sub_messages.contains(&message) {
326 self.sub_messages.push(message.clone());
327 }
328 Ok(())
329 }
330
331 pub fn poll_events(&mut self) -> Vec<Event> {
333 let mut events = Vec::new();
334 if let Some(connection) = self.connection.as_mut() {
335 while let Ok(Some(frame)) = connection.recv() {
336 if let Ok(mut parsed) = parse_ws_message(&frame) {
337 events.append(&mut parsed);
338 }
339 }
340 }
341 let url = "wss://advanced-trade-ws.coinbase.com";
342 crate::wsutil::reconnect_if_dropped(
343 self.ws.as_deref(),
344 url,
345 &mut self.connection,
346 &self.sub_messages,
347 &mut events,
348 );
349 events
350 }
351
352 fn ensure_no_stp_inner(request: &OrderRequest) -> Result<()> {
362 if request.stp == SelfTradePrevention::None {
363 return Ok(());
364 }
365 Err(Error::unsupported_field(
366 "Coinbase",
367 "a per-order self-trade-prevention policy",
368 "Coinbase sets self-trade prevention per portfolio",
369 ))
370 }
371
372 fn ensure_reduce_only_is_reducible(request: &OrderRequest) -> Result<()> {
379 if request.reduce_only {
380 return Err(Error::unsupported_field(
381 "Coinbase",
382 "reduce_only on a spot order",
383 "Coinbase Advanced Trade is spot-only and holds no positions to reduce",
384 ));
385 }
386 Ok(())
387 }
388
389 fn stop_configuration(request: &OrderRequest, base_size: &str) -> Result<serde_json::Value> {
402 let stop = request
403 .stop_price
404 .ok_or(Error::InvalidOrder("a trigger order requires a stop price"))?;
405 let Some(price) = request.price else {
406 return Err(Error::unsupported_field(
407 "Coinbase",
408 "a stop-market order",
409 "Coinbase's only trigger configuration is `stop_limit_stop_limit_gtc`, \
410 and choosing a limit price here would decide how much slippage the \
411 caller's stop may take",
412 ));
413 };
414 let direction = match request.side {
415 OrderSide::Sell => "STOP_DIRECTION_STOP_DOWN",
416 OrderSide::Buy => "STOP_DIRECTION_STOP_UP",
417 };
418 Ok(serde_json::json!({
419 "stop_limit_stop_limit_gtc": {
420 "base_size": base_size,
421 "limit_price": format_decimal(price),
422 "stop_price": format_decimal(stop),
423 "stop_direction": direction,
424 }
425 }))
426 }
427
428 pub fn place_order(&self, request: &OrderRequest) -> Result<Order> {
429 Self::ensure_reduce_only_is_reducible(request)?;
430 request.validate()?;
431 let client_order_id = request
432 .client_order_id
433 .clone()
434 .unwrap_or_else(|| self.client_ids.next_id());
435 let base_size = format_decimal(request.quantity);
436 Self::ensure_no_stp_inner(request)?;
437 if request.order_type.is_trigger() {
445 let configuration = Self::stop_configuration(request, &base_size)?;
446 return self.submit_order(request, &client_order_id, &configuration);
447 }
448 let configuration = match (request.order_type, request.time_in_force) {
449 (OrderType::Market | OrderType::StopMarket, TimeInForce::Fok) => {
450 return Err(Error::unsupported_field(
451 "Coinbase",
452 "a FOK time-in-force on a market order",
453 "Coinbase's market configuration is `market_market_ioc`",
454 ))
455 }
456 (OrderType::Market | OrderType::StopMarket, _) => {
457 if request.post_only {
458 return Err(Error::unsupported_field(
459 "Coinbase",
460 "post_only on a market order",
461 "`post_only` belongs to the limit configurations",
462 ));
463 }
464 serde_json::json!({ "market_market_ioc": { "base_size": base_size } })
465 }
466 (OrderType::Limit | OrderType::StopLimit, TimeInForce::Ioc) => {
467 return Err(Error::unsupported_field(
468 "Coinbase",
469 "an IOC time-in-force on a limit order",
470 "Coinbase's limit configurations are GTC, GTD and FOK",
471 ))
472 }
473 (OrderType::Limit | OrderType::StopLimit, tif) => {
474 let price = request
475 .price
476 .ok_or(Error::InvalidOrder("limit order requires a price"))?;
477 if tif == TimeInForce::Fok {
478 if request.post_only {
479 return Err(Error::unsupported_field(
480 "Coinbase",
481 "post_only together with a FOK time-in-force",
482 "`limit_limit_fok` carries no post-only flag",
483 ));
484 }
485 serde_json::json!({ "limit_limit_fok": {
486 "base_size": base_size,
487 "limit_price": format_decimal(price),
488 }})
489 } else {
490 serde_json::json!({ "limit_limit_gtc": {
491 "base_size": base_size,
492 "limit_price": format_decimal(price),
493 "post_only": request.post_only,
494 }})
495 }
496 }
497 };
498 self.submit_order(request, &client_order_id, &configuration)
499 }
500 fn submit_order(
506 &self,
507 request: &OrderRequest,
508 client_order_id: &str,
509 configuration: &serde_json::Value,
510 ) -> Result<Order> {
511 let body = serde_json::json!({
512 "client_order_id": client_order_id,
513 "product_id": Self::wire_symbol(&request.symbol),
514 "side": side_str(request.side),
515 "order_configuration": configuration,
516 });
517 let value = self.signed_post("/api/v3/brokerage/orders", &body.to_string())?;
518 if value.get("success").and_then(serde_json::Value::as_bool) == Some(false) {
519 let err = value.get("error_response");
520 return Err(Error::OrderRejected {
521 code: err
522 .and_then(|e| e.get("error"))
523 .and_then(serde_json::Value::as_str)
524 .unwrap_or("rejected")
525 .to_string(),
526 message: err
527 .and_then(|e| e.get("message"))
528 .and_then(serde_json::Value::as_str)
529 .unwrap_or("")
530 .to_string(),
531 });
532 }
533 let order_id = value
534 .get("success_response")
535 .and_then(|r| r.get("order_id"))
536 .or_else(|| value.get("order_id"))
537 .and_then(serde_json::Value::as_str)
538 .ok_or_else(|| Error::Deserialization("missing order id".to_string()))?;
539 Ok(Order {
540 id: order_id.to_string(),
541 client_order_id: Some(client_order_id.to_string()),
542 symbol: request.symbol.clone(),
543 side: request.side,
544 order_type: request.order_type,
545 status: OrderStatus::New,
546 quantity: request.quantity,
547 filled_quantity: Decimal::ZERO,
548 price: request.price,
549 average_price: None,
550 })
551 }
552
553 pub fn cancel_order(&self, _symbol: &Symbol, order_id: &str) -> Result<()> {
558 let body = serde_json::json!({ "order_ids": [order_id] });
559 self.signed_post("/api/v3/brokerage/orders/batch_cancel", &body.to_string())?;
560 Ok(())
561 }
562
563 pub fn query_order(&self, symbol: &Symbol, order_id: &str) -> Result<Order> {
568 let path = format!("/api/v3/brokerage/orders/historical/{order_id}");
569 let value = self.signed_get(&path, "")?;
570 let order = value
571 .get("order")
572 .ok_or_else(|| Error::NotFound(format!("order {order_id}")))?;
573 order_from_value(symbol.clone(), order)
574 }
575
576 pub fn open_orders(&self, symbol: Option<&Symbol>) -> Result<Vec<Order>> {
581 let mut query = "order_status=OPEN".to_string();
582 if let Some(s) = symbol {
583 query.push_str("&product_id=");
584 query.push_str(&Self::wire_symbol(s));
585 }
586 let value = self.signed_get("/api/v3/brokerage/orders/historical/batch", &query)?;
587 let orders = value
588 .get("orders")
589 .and_then(serde_json::Value::as_array)
590 .ok_or_else(|| Error::Deserialization("missing orders".to_string()))?;
591 orders
592 .iter()
593 .map(|order| {
594 let sym = symbol.cloned().unwrap_or_else(|| {
595 let raw = order
596 .get("product_id")
597 .and_then(serde_json::Value::as_str)
598 .unwrap_or("");
599 raw.parse().unwrap_or_else(|_| Symbol::new(raw, ""))
600 });
601 order_from_value(sym, order)
602 })
603 .collect()
604 }
605
606 pub fn balances(&self) -> Result<Vec<Balance>> {
611 let value = self.signed_get("/api/v3/brokerage/accounts", "")?;
612 let accounts = value
613 .get("accounts")
614 .and_then(serde_json::Value::as_array)
615 .ok_or_else(|| Error::Deserialization("missing accounts".to_string()))?;
616 accounts
617 .iter()
618 .map(|a| {
619 let asset = a
620 .get("currency")
621 .and_then(serde_json::Value::as_str)
622 .unwrap_or("");
623 let free = nested_value(a, "available_balance");
624 let hold = nested_value(a, "hold");
625 Ok(Balance {
626 asset: asset.to_string(),
627 free: parse_decimal(&free)?,
628 locked: parse_decimal(&hold)?,
629 })
630 })
631 .collect()
632 }
633
634 fn build_jwt(&self, uri: Option<String>) -> Result<String> {
636 let creds = self.credentials.as_ref().ok_or(Error::InvalidCredentials(
637 "signed endpoint requires credentials",
638 ))?;
639 let private_key = creds
640 .private_key
641 .as_deref()
642 .ok_or(Error::InvalidCredentials(
643 "Coinbase requires an EC private key",
644 ))?;
645 let signing_key = SigningKey::from_pkcs8_pem(private_key)
646 .map_err(|_| Error::InvalidCredentials("invalid EC private key"))?;
647 let now = self.signed_now_ms() / 1000;
648 let nonce = format!("{:016x}", (self.now_ms)());
649 let header = serde_json::json!({
650 "alg": "ES256",
651 "kid": creds.api_key,
652 "typ": "JWT",
653 "nonce": nonce,
654 });
655 let mut payload = serde_json::json!({
656 "sub": creds.api_key,
657 "iss": "cdp",
658 "nbf": now,
659 "exp": now + 120,
660 });
661 if let Some(uri) = uri {
662 payload["uri"] = serde_json::json!(uri);
663 }
664 let signing_input = format!(
665 "{}.{}",
666 b64url(header.to_string().as_bytes()),
667 b64url(payload.to_string().as_bytes())
668 );
669 let signature: Signature = signing_key.sign(signing_input.as_bytes());
670 Ok(format!(
671 "{signing_input}.{}",
672 b64url(signature.to_bytes().as_slice())
673 ))
674 }
675
676 fn signed_get(&self, path: &str, query: &str) -> Result<serde_json::Value> {
677 self.ensure_market_is_routed()?;
678 let jwt = self.build_jwt(Some(format!("GET {HOST}{path}")))?;
679 let url = if query.is_empty() {
680 format!("{}{path}", self.rest_base)
681 } else {
682 format!("{}{path}?{query}", self.rest_base)
683 };
684 let request = HttpRequest::get(url).with_header("Authorization", format!("Bearer {jwt}"));
685 let response = self.http.execute(&request)?;
686 parse_body(&response)
687 }
688
689 fn signed_post(&self, path: &str, body: &str) -> Result<serde_json::Value> {
690 self.ensure_market_is_routed()?;
691 let jwt = self.build_jwt(Some(format!("POST {HOST}{path}")))?;
692 let url = format!("{}{path}", self.rest_base);
693 let request = HttpRequest::new(HttpMethod::Post, url)
694 .with_header("Authorization", format!("Bearer {jwt}"))
695 .with_header("Content-Type", "application/json")
696 .with_body(body.to_string());
697 let response = self.http.execute(&request)?;
698 parse_body(&response)
699 }
700}
701
702fn granularity(interval: &str) -> (&'static str, i64) {
703 match interval {
704 "1m" => ("ONE_MINUTE", 60),
705 "5m" => ("FIVE_MINUTE", 300),
706 "15m" => ("FIFTEEN_MINUTE", 900),
707 "30m" => ("THIRTY_MINUTE", 1800),
708 "2h" => ("TWO_HOUR", 7200),
709 "6h" => ("SIX_HOUR", 21600),
710 "1d" => ("ONE_DAY", 86400),
711 _ => ("ONE_HOUR", 3600),
712 }
713}
714
715fn parse_body(response: &HttpResponse) -> Result<serde_json::Value> {
716 if response.is_success() {
717 serde_json::from_str(&response.body).map_err(|e| Error::Deserialization(e.to_string()))
718 } else {
719 let value: serde_json::Value = serde_json::from_str(&response.body).unwrap_or_default();
720 let error = value
721 .get("error")
722 .and_then(serde_json::Value::as_str)
723 .unwrap_or("");
724 let message = value
725 .get("message")
726 .and_then(serde_json::Value::as_str)
727 .unwrap_or("");
728 Err(map_error(error, message, response.status).with_retry_after(response.retry_after()))
729 }
730}
731
732fn map_error(error: &str, message: &str, status: u16) -> Error {
733 let text = format!("{error} {message}");
734 if error.contains("INSUFFICIENT_FUND") || text.contains("Insufficient") {
735 Error::InsufficientBalance
736 } else if status == 401 || error.contains("UNAUTHORIZED") || text.contains("authentication") {
737 Error::Auth(message.to_string())
738 } else if status == 429 || error.contains("RATE") {
739 Error::RateLimited { retry_after: None }
740 } else if status == 404 || error.contains("NOT_FOUND") {
741 Error::NotFound(message.to_string())
742 } else if error.contains("INVALID") && text.contains("product") {
743 Error::InvalidSymbol(message.to_string())
744 } else {
745 Error::Exchange {
746 code: if error.is_empty() {
747 status.to_string()
748 } else {
749 error.to_string()
750 },
751 message: message.to_string(),
752 }
753 }
754}
755
756fn side_str(side: OrderSide) -> &'static str {
757 match side {
758 OrderSide::Buy => "BUY",
759 OrderSide::Sell => "SELL",
760 }
761}
762
763fn parse_side(raw: &str) -> Result<OrderSide> {
764 match raw {
765 "BUY" => Ok(OrderSide::Buy),
766 "SELL" => Ok(OrderSide::Sell),
767 other => Err(Error::Deserialization(format!("unknown side {other:?}"))),
768 }
769}
770
771fn parse_status(raw: &str) -> Result<OrderStatus> {
772 match raw {
773 "OPEN" | "PENDING" | "QUEUED" => Ok(OrderStatus::New),
774 "FILLED" => Ok(OrderStatus::Filled),
775 "CANCELLED" | "CANCEL_QUEUED" => Ok(OrderStatus::Canceled),
776 "EXPIRED" => Ok(OrderStatus::Expired),
777 "FAILED" | "REJECTED" => Ok(OrderStatus::Rejected),
778 other => Err(Error::Deserialization(format!("unknown status {other:?}"))),
779 }
780}
781
782fn nonzero(value: Decimal) -> Option<Decimal> {
783 (value > Decimal::ZERO).then_some(value)
784}
785
786fn str_field<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
787 value
788 .get(key)
789 .and_then(serde_json::Value::as_str)
790 .ok_or_else(|| Error::Deserialization(format!("missing string field {key:?}")))
791}
792
793fn opt_dec(value: &serde_json::Value, key: &str) -> Decimal {
794 value
795 .get(key)
796 .and_then(serde_json::Value::as_str)
797 .and_then(|s| parse_decimal(s).ok())
798 .unwrap_or(Decimal::ZERO)
799}
800
801fn nested_value(value: &serde_json::Value, key: &str) -> String {
803 value
804 .get(key)
805 .and_then(|v| v.get("value"))
806 .and_then(serde_json::Value::as_str)
807 .unwrap_or("0")
808 .to_string()
809}
810
811fn object_levels(value: Option<&serde_json::Value>) -> Result<Vec<BookLevel>> {
813 let array = value
814 .and_then(serde_json::Value::as_array)
815 .ok_or_else(|| Error::Deserialization("missing levels".to_string()))?;
816 array
817 .iter()
818 .map(|level| {
819 Ok(BookLevel {
820 price: parse_decimal(str_field(level, "price")?)?,
821 quantity: parse_decimal(str_field(level, "size")?)?,
822 })
823 })
824 .collect()
825}
826
827fn parse_candle(candle: &serde_json::Value) -> Result<Candle> {
828 let ts = str_field(candle, "start")?
829 .parse::<i64>()
830 .map_err(|e| Error::Deserialization(format!("candle start not an integer: {e}")))?;
831 let f = |key: &str| -> Result<f64> {
832 str_field(candle, key)?
833 .parse::<f64>()
834 .map_err(|e| Error::Deserialization(format!("candle {key} not a number: {e}")))
835 };
836 Candle::new(
837 f("open")?,
838 f("high")?,
839 f("low")?,
840 f("close")?,
841 f("volume")?,
842 ts,
843 )
844 .map_err(|e| Error::Deserialization(e.to_string()))
845}
846
847fn order_from_value(symbol: Symbol, order: &serde_json::Value) -> Result<Order> {
848 let side = str_field(order, "side")?;
849 let status = str_field(order, "status")?;
850 let config = order.get("order_configuration");
851 let (order_type, limit_price) = if let Some(limit) = config.and_then(|c| {
852 c.get("limit_limit_gtc")
853 .or_else(|| c.get("limit_limit_gtd"))
854 }) {
855 (OrderType::Limit, opt_dec(limit, "limit_price"))
856 } else {
857 (OrderType::Market, Decimal::ZERO)
858 };
859 let quantity = config
860 .and_then(|c| c.as_object())
861 .and_then(|obj| obj.values().next())
862 .map_or(Decimal::ZERO, |cfg| opt_dec(cfg, "base_size"));
863 Ok(Order {
864 id: str_field(order, "order_id")?.to_string(),
865 client_order_id: order
866 .get("client_order_id")
867 .and_then(serde_json::Value::as_str)
868 .filter(|s| !s.is_empty())
869 .map(str::to_string),
870 symbol,
871 side: parse_side(side)?,
872 order_type,
873 status: parse_status(status)?,
874 quantity,
875 filled_quantity: opt_dec(order, "filled_size"),
876 price: nonzero(limit_price),
877 average_price: nonzero(opt_dec(order, "average_filled_price")),
878 })
879}
880
881fn parse_ws_message(text: &str) -> Result<Vec<Event>> {
882 let value: serde_json::Value =
883 serde_json::from_str(text).map_err(|e| Error::Deserialization(e.to_string()))?;
884 let Some(channel) = value.get("channel").and_then(serde_json::Value::as_str) else {
885 return Ok(Vec::new());
886 };
887 let empty = Vec::new();
888 let events = value
889 .get("events")
890 .and_then(serde_json::Value::as_array)
891 .unwrap_or(&empty);
892 let mut out = Vec::new();
893
894 match channel {
895 "market_trades" => {
896 for event in events {
897 let trades = event.get("trades").and_then(serde_json::Value::as_array);
898 for trade in trades.unwrap_or(&empty) {
899 let product = str_field(trade, "product_id")?;
900 out.push(Event::Trade(TradePrint {
901 symbol: product.parse().unwrap_or_else(|_| Symbol::new(product, "")),
902 price: parse_decimal(str_field(trade, "price")?)?,
903 quantity: parse_decimal(str_field(trade, "size")?)?,
904 aggressor: parse_side(str_field(trade, "side")?)?,
905 timestamp: 0,
906 }));
907 }
908 }
909 Ok(out)
910 }
911 "ticker" => {
912 for event in events {
913 let tickers = event.get("tickers").and_then(serde_json::Value::as_array);
914 for ticker in tickers.unwrap_or(&empty) {
915 let product = str_field(ticker, "product_id")?;
916 out.push(Event::Ticker(Ticker {
917 symbol: product.parse().unwrap_or_else(|_| Symbol::new(product, "")),
918 last: parse_decimal(str_field(ticker, "price")?)?,
919 bid: opt_dec(ticker, "best_bid"),
920 ask: opt_dec(ticker, "best_ask"),
921 volume: opt_dec(ticker, "volume_24_h"),
922 timestamp: 0,
923 }));
924 }
925 }
926 Ok(out)
927 }
928 "l2_data" => {
929 for event in events {
930 let product = event
931 .get("product_id")
932 .and_then(serde_json::Value::as_str)
933 .unwrap_or("");
934 let symbol = product.parse().unwrap_or_else(|_| Symbol::new(product, ""));
935 let mut bids = Vec::new();
936 let mut asks = Vec::new();
937 if let Some(updates) = event.get("updates").and_then(serde_json::Value::as_array) {
938 for update in updates {
939 let level = BookLevel {
940 price: parse_decimal(str_field(update, "price_level")?)?,
941 quantity: parse_decimal(str_field(update, "new_quantity")?)?,
942 };
943 match update.get("side").and_then(serde_json::Value::as_str) {
944 Some("bid") => bids.push(level),
945 _ => asks.push(level),
946 }
947 }
948 }
949 out.push(Event::BookDelta(BookDelta {
950 symbol,
951 first_update_id: 0,
952 final_update_id: 0,
953 bids,
954 asks,
955 timestamp: 0,
956 }));
957 }
958 Ok(out)
959 }
960 _ => Ok(Vec::new()),
961 }
962}
963
964impl MarketData for Coinbase {
965 fn ticker(&mut self, symbol: &Symbol) -> Result<Ticker> {
966 Coinbase::ticker(self, symbol)
967 }
968 fn klines(&mut self, symbol: &Symbol, interval: &str, limit: u32) -> Result<Vec<Candle>> {
969 Coinbase::klines(self, symbol, interval, limit)
970 }
971 fn order_book(&mut self, symbol: &Symbol, depth: u32) -> Result<OrderBookSnapshot> {
972 Coinbase::order_book(self, symbol, depth)
973 }
974 fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
975 Coinbase::subscribe_trades(self, symbol)
976 }
977 fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
978 Coinbase::subscribe_book(self, symbol)
979 }
980 fn subscribe_ticker(&mut self, symbol: &Symbol) -> Result<()> {
981 Coinbase::subscribe_ticker(self, symbol)
982 }
983 fn poll_events(&mut self) -> Vec<Event> {
984 Coinbase::poll_events(self)
985 }
986}
987
988impl Execution for Coinbase {
989 fn place_order(&mut self, request: &OrderRequest) -> Result<Order> {
990 Coinbase::place_order(self, request)
991 }
992 fn cancel_order(&mut self, symbol: &Symbol, order_id: &str) -> Result<()> {
993 Coinbase::cancel_order(self, symbol, order_id)
994 }
995 fn query_order(&mut self, symbol: &Symbol, order_id: &str) -> Result<Order> {
996 Coinbase::query_order(self, symbol, order_id)
997 }
998 fn open_orders(&mut self, symbol: Option<&Symbol>) -> Result<Vec<Order>> {
999 Coinbase::open_orders(self, symbol)
1000 }
1001 fn balances(&mut self) -> Result<Vec<Balance>> {
1002 Coinbase::balances(self)
1003 }
1004}
1005
1006impl Exchange for Coinbase {
1007 fn name(&self) -> &'static str {
1008 "coinbase"
1009 }
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014 use super::*;
1015 use crate::transport::{MockHttpTransport, MockWsTransport};
1016 use p256::ecdsa::{signature::Verifier, VerifyingKey};
1017 use rust_decimal_macros::dec;
1018 use std::sync::Arc;
1019
1020 const TEST_KEY: &str = "-----BEGIN PRIVATE KEY-----\n\
1022MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgZZ/YugITtxORUz74\n\
1023wHvqY4aizCFHQFTVNQCzDGy8/TOhRANCAAS69zNVQjOQ4RgxJVI8esP+jMfHLSTw\n\
10242iVqo0qWlda/1D2jN4O3zcv4juQF5iE4pU5qPkeECTgsKSIYwZaMVMyO\n\
1025-----END PRIVATE KEY-----\n";
1026
1027 struct ArcTransport(Arc<MockHttpTransport>);
1028 impl HttpTransport for ArcTransport {
1029 fn execute(&self, request: &HttpRequest) -> Result<HttpResponse> {
1030 self.0.execute(request)
1031 }
1032 }
1033 struct ArcWs(Arc<MockWsTransport>);
1034 impl WsTransport for ArcWs {
1035 fn connect(&self, url: &str) -> Result<Box<dyn WsConnection>> {
1036 self.0.connect(url)
1037 }
1038 }
1039
1040 fn symbol() -> Symbol {
1041 Symbol::new("BTC", "USD")
1042 }
1043
1044 fn signed_client(now_ms: i64) -> (Coinbase, Arc<MockHttpTransport>) {
1045 let mock = Arc::new(MockHttpTransport::new());
1046 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
1047 let coinbase = Coinbase::with_credentials(
1048 Box::new(ArcTransport(Arc::clone(&mock))),
1049 &opts,
1050 Credentials::new("organizations/x/apiKeys/y", "unused").with_private_key(TEST_KEY),
1051 )
1052 .with_clock(Box::new(move || now_ms));
1053 (coinbase, mock)
1054 }
1055
1056 #[test]
1064 fn a_stop_limit_carries_its_configuration_and_direction() {
1065 for (side, expected) in [
1066 (OrderSide::Sell, "STOP_DIRECTION_STOP_DOWN"),
1067 (OrderSide::Buy, "STOP_DIRECTION_STOP_UP"),
1068 ] {
1069 let (coinbase, mock) = signed_client(1000);
1070 mock.push_json(
1071 200,
1072 r#"{"success":true,"success_response":{"order_id":"o-1"}}"#,
1073 );
1074 let request = OrderRequest {
1075 order_type: OrderType::StopLimit,
1076 stop_price: Some(dec!(19000)),
1077 side,
1078 ..OrderRequest::limit_sell(symbol(), dec!(1), dec!(18900))
1079 };
1080 Coinbase::place_order(&coinbase, &request).unwrap();
1081
1082 let body = mock.recorded_requests()[0].body.clone().unwrap();
1083 assert!(body.contains("stop_limit_stop_limit_gtc"), "{body}");
1084 assert!(body.contains(r#""stop_price":"19000""#), "{body}");
1085 assert!(body.contains(r#""limit_price":"18900""#), "{body}");
1086 assert!(
1087 body.contains(expected),
1088 "a {side:?} stop fires the other way: {body}"
1089 );
1090 }
1091 }
1092
1093 #[test]
1100 fn a_stop_market_is_refused_rather_than_given_an_invented_limit() {
1101 let (coinbase, mock) = signed_client(1000);
1102 let request = OrderRequest {
1103 order_type: OrderType::StopMarket,
1104 stop_price: Some(dec!(19000)),
1105 ..OrderRequest::market_sell(symbol(), dec!(1))
1106 };
1107 let err = Coinbase::place_order(&coinbase, &request)
1108 .expect_err("Coinbase has no stop-market configuration");
1109 assert!(matches!(err, Error::Exchange { ref code, .. } if code == "unsupported"));
1110 assert!(mock.recorded_requests().is_empty(), "refused, yet sent");
1111 }
1112
1113 #[test]
1114 fn a_coinbase_trigger_without_a_stop_price_is_refused() {
1115 let (coinbase, mock) = signed_client(1000);
1116 let request = OrderRequest {
1117 order_type: OrderType::StopLimit,
1118 stop_price: None,
1119 ..OrderRequest::limit_sell(symbol(), dec!(1), dec!(18900))
1120 };
1121 assert!(Coinbase::place_order(&coinbase, &request).is_err());
1122 assert_eq!(mock.recorded_requests(), Vec::new());
1123 }
1124
1125 #[test]
1126 fn jwt_is_valid_es256_and_carries_uri() {
1127 let (coinbase, mock) = signed_client(1_000_000);
1128 mock.push_json(
1129 200,
1130 r#"{"trades":[{"price":"20000"}],"best_bid":"19999","best_ask":"20001"}"#,
1131 );
1132 coinbase.ticker(&symbol()).unwrap();
1133
1134 let auth = mock.recorded_requests()[0]
1135 .headers
1136 .iter()
1137 .find(|(k, _)| k == "Authorization")
1138 .map(|(_, v)| v.clone())
1139 .unwrap();
1140 let jwt = auth.strip_prefix("Bearer ").unwrap();
1141 let parts: Vec<&str> = jwt.split('.').collect();
1142 assert_eq!(parts.len(), 3);
1143
1144 let engine = base64::engine::general_purpose::URL_SAFE_NO_PAD;
1146 let header: serde_json::Value =
1147 serde_json::from_slice(&engine.decode(parts[0]).unwrap()).unwrap();
1148 assert_eq!(header["alg"], "ES256");
1149 let payload: serde_json::Value =
1150 serde_json::from_slice(&engine.decode(parts[1]).unwrap()).unwrap();
1151 assert_eq!(
1152 payload["uri"],
1153 "GET api.coinbase.com/api/v3/brokerage/products/BTC-USD/ticker"
1154 );
1155 assert_eq!(payload["iss"], "cdp");
1156
1157 let signing_key = SigningKey::from_pkcs8_pem(TEST_KEY).unwrap();
1159 let verifying_key = VerifyingKey::from(&signing_key);
1160 let signing_input = format!("{}.{}", parts[0], parts[1]);
1161 let sig_bytes = engine.decode(parts[2]).unwrap();
1162 let signature = Signature::from_slice(&sig_bytes).unwrap();
1163 assert!(verifying_key
1164 .verify(signing_input.as_bytes(), &signature)
1165 .is_ok());
1166 }
1167
1168 #[test]
1172 fn configurations_that_cannot_hold_both_settings_are_refused() {
1173 let (coinbase, mock) = signed_client(1000);
1174 let market_post_only = OrderRequest::market_buy(symbol(), dec!(1)).post_only();
1175 let err = coinbase.place_order(&market_post_only).unwrap_err();
1176 assert!(matches!(err, Error::Exchange { ref code, .. } if code == "unsupported"));
1177 assert_eq!(mock.recorded_requests(), Vec::new());
1178
1179 let (coinbase, mock) = signed_client(1000);
1180 let fok_post_only = OrderRequest::limit_buy(symbol(), dec!(1), dec!(100))
1181 .post_only()
1182 .with_time_in_force(TimeInForce::Fok);
1183 let err = coinbase.place_order(&fok_post_only).unwrap_err();
1184 assert!(matches!(err, Error::Exchange { ref code, .. } if code == "unsupported"));
1185 assert_eq!(mock.recorded_requests(), Vec::new());
1186 }
1187
1188 #[test]
1189 fn ticker_parses() {
1190 let (coinbase, mock) = signed_client(1_000_000);
1191 mock.push_json(
1192 200,
1193 r#"{"trades":[{"price":"20000.5"}],"best_bid":"19999","best_ask":"20001"}"#,
1194 );
1195 let ticker = coinbase.ticker(&symbol()).unwrap();
1196 assert_eq!(ticker.last, dec!(20000.5));
1197 assert_eq!(ticker.bid, dec!(19999));
1198 }
1199
1200 #[test]
1201 fn klines_reversed() {
1202 let (coinbase, mock) = signed_client(1_000_000);
1203 mock.push_json(
1204 200,
1205 r#"{"candles":[
1206 {"start":"1700003600","low":"104","high":"106","open":"105","close":"105.5","volume":"2"},
1207 {"start":"1700000000","low":"95","high":"110","open":"100","close":"105","volume":"12"}]}"#,
1208 );
1209 let candles = coinbase.klines(&symbol(), "1h", 2).unwrap();
1210 assert_eq!(candles[0].timestamp, 1_700_000_000);
1211 assert!((candles[0].high - 110.0).abs() < 1e-9);
1212 }
1213
1214 #[test]
1215 fn order_book_parses_object_levels() {
1216 let (coinbase, mock) = signed_client(1_000_000);
1217 mock.push_json(
1218 200,
1219 r#"{"pricebook":{"bids":[{"price":"100","size":"1"}],"asks":[{"price":"101","size":"2"}]}}"#,
1220 );
1221 let book = coinbase.order_book(&symbol(), 5).unwrap();
1222 assert_eq!(book.bids[0], BookLevel::new(dec!(100), dec!(1)));
1223 }
1224
1225 #[test]
1226 fn place_order_limit_configuration() {
1227 let (coinbase, mock) = signed_client(1_000_000);
1228 mock.push_json(
1229 200,
1230 r#"{"success":true,"success_response":{"order_id":"OID-1"}}"#,
1231 );
1232 let order = coinbase
1233 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
1234 .unwrap();
1235 assert_eq!(order.id, "OID-1");
1236 let reqs = mock.recorded_requests();
1237 let body = reqs[0].body.as_ref().unwrap();
1238 assert!(body.contains(r#""limit_limit_gtc""#));
1239 assert!(body.contains(r#""side":"BUY""#));
1240 }
1241
1242 #[test]
1243 fn place_order_rejection() {
1244 let (coinbase, mock) = signed_client(1_000_000);
1245 mock.push_json(
1246 200,
1247 r#"{"success":false,"error_response":{"error":"INSUFFICIENT_FUND","message":"no"}}"#,
1248 );
1249 assert!(matches!(
1250 coinbase
1251 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
1252 .unwrap_err(),
1253 Error::OrderRejected { .. }
1254 ));
1255 }
1256
1257 #[test]
1258 fn query_order_and_balances() {
1259 let (coinbase, mock) = signed_client(1_000_000);
1260 mock.push_json(
1261 200,
1262 r#"{"order":{"order_id":"OID-1","client_order_id":"","product_id":"BTC-USD","side":"SELL",
1263 "status":"FILLED","filled_size":"2","average_filled_price":"100",
1264 "order_configuration":{"market_market_ioc":{"base_size":"2"}}}}"#,
1265 );
1266 let order = coinbase.query_order(&symbol(), "OID-1").unwrap();
1267 assert_eq!(order.status, OrderStatus::Filled);
1268 assert_eq!(order.side, OrderSide::Sell);
1269 assert_eq!(order.order_type, OrderType::Market);
1270 assert_eq!(order.average_price, Some(dec!(100)));
1271
1272 mock.push_json(
1273 200,
1274 r#"{"accounts":[{"currency":"USDC","available_balance":{"value":"100.5"},"hold":{"value":"25.5"}}]}"#,
1275 );
1276 let bals = coinbase.balances().unwrap();
1277 assert_eq!(bals[0].asset, "USDC");
1278 assert_eq!(bals[0].total(), dec!(126));
1279 }
1280
1281 #[test]
1282 fn signed_requires_private_key() {
1283 let mock = Arc::new(MockHttpTransport::new());
1284 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
1285 let coinbase = Coinbase::with_credentials(
1286 Box::new(ArcTransport(mock)),
1287 &opts,
1288 Credentials::new("k", "s"),
1289 );
1290 assert!(matches!(
1291 coinbase.balances().unwrap_err(),
1292 Error::InvalidCredentials(_)
1293 ));
1294 }
1295
1296 #[test]
1297 fn ws_parses_trades_and_l2() {
1298 let ws = Arc::new(MockWsTransport::new());
1299 ws.push_connection(vec![
1300 Ok(Some(
1301 r#"{"channel":"market_trades","events":[{"type":"update","trades":[
1302 {"product_id":"BTC-USD","price":"100","size":"0.5","side":"BUY"}]}]}"#
1303 .to_string(),
1304 )),
1305 Ok(Some(
1306 r#"{"channel":"l2_data","events":[{"type":"update","product_id":"BTC-USD","updates":[
1307 {"side":"bid","price_level":"100","new_quantity":"1"},
1308 {"side":"offer","price_level":"101","new_quantity":"2"}]}]}"#
1309 .to_string(),
1310 )),
1311 ]);
1312 let (_c, _m) = signed_client(1_000_000);
1313 let mock = Arc::new(MockHttpTransport::new());
1314 let opts = ExchangeOptions::mainnet(crate::MarketType::Spot);
1315 let mut coinbase = Coinbase::with_credentials(
1316 Box::new(ArcTransport(mock)),
1317 &opts,
1318 Credentials::new("kid", "s").with_private_key(TEST_KEY),
1319 )
1320 .with_clock(Box::new(|| 1_000_000))
1321 .with_ws(Box::new(ArcWs(Arc::clone(&ws))));
1322 coinbase.subscribe_trades(&symbol()).unwrap();
1323 assert!(ws.sent()[0].contains(r#""channel":"market_trades""#));
1324 assert!(ws.sent()[0].contains(r#""jwt":"#));
1325
1326 let events = coinbase.poll_events();
1327 assert_eq!(events.len(), 2);
1328 let Event::Trade(t) = &events[0] else {
1329 panic!("expected trade")
1330 };
1331 assert_eq!(t.aggressor, OrderSide::Buy);
1332 let Event::BookDelta(d) = &events[1] else {
1333 panic!("expected book delta")
1334 };
1335 assert_eq!(d.bids.len(), 1);
1336 assert_eq!(d.asks.len(), 1);
1337 }
1338
1339 #[test]
1340 fn works_as_a_boxed_exchange() {
1341 let (coinbase, mock) = signed_client(1_000_000);
1342 mock.push_json(
1343 200,
1344 r#"{"success":true,"success_response":{"order_id":"O1"}}"#,
1345 );
1346 let mut exchange: Box<dyn Exchange> = Box::new(coinbase);
1347 assert_eq!(exchange.name(), "coinbase");
1348 let order = exchange
1349 .place_order(&OrderRequest::limit_buy(symbol(), dec!(1), dec!(100)))
1350 .unwrap();
1351 assert_eq!(order.id, "O1");
1352 }
1353
1354 #[test]
1355 fn system_clock_is_sane() {
1356 assert!(system_now_ms() > 1_600_000_000_000);
1357 }
1358
1359 #[test]
1363 fn debug_reports_state_without_credentials() {
1364 let (client, _http) = signed_client(1_700_000_000_000);
1365 let rendered = format!("{client:?}");
1366
1367 assert!(rendered.starts_with("Coinbase {"));
1368 assert!(rendered.contains("authenticated: true"));
1369 assert!(!rendered.contains("BEGIN PRIVATE KEY"));
1371 assert!(!rendered.contains("MIGHAgEAMBMGByqGSM49"));
1372 assert!(!rendered.contains("organizations/x/apiKeys/y"));
1373 }
1374
1375 #[test]
1378 fn sync_time_shifts_the_jwt() {
1379 let (mut coinbase, http) = signed_client(1_000_000);
1380
1381 http.push_json(200, r#"{"epochMillis":"1004500"}"#);
1384 let offset = coinbase.sync_time().expect("sync must succeed");
1385 assert_eq!(offset, 4_500, "the venue is 4.5 s ahead of this machine");
1386
1387 http.push_json(200, r#"{"accounts":[]}"#);
1388 coinbase.balances().expect("balances must succeed");
1389
1390 let requests = http.recorded_requests();
1391 let bearer = requests
1392 .last()
1393 .and_then(|request| {
1394 request
1395 .headers
1396 .iter()
1397 .find(|(name, _)| name == "Authorization")
1398 })
1399 .map(|(_, value)| value.trim_start_matches("Bearer ").to_string())
1400 .expect("the signed request carries a bearer token");
1401
1402 let payload = bearer.split('.').nth(1).expect("a JWT has three segments");
1403 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
1404 .decode(payload)
1405 .expect("the payload is base64url");
1406 let claims: serde_json::Value =
1407 serde_json::from_slice(&decoded).expect("the payload is JSON");
1408
1409 assert_eq!(
1410 claims["nbf"], 1004,
1411 "the JWT must be stamped with the venue's time, not ours"
1412 );
1413 assert_eq!(claims["exp"], 1004 + 120, "the window moves with it");
1414 }
1415
1416 #[test]
1424 fn rate_limit_carries_the_venues_advised_wait() {
1425 let (coinbase, mock) = signed_client(0);
1426 mock.push_response(
1427 crate::transport::HttpResponse::new(
1428 429,
1429 r#"{"error":"RATE_LIMIT","message":"slow down"}"#,
1430 )
1431 .with_header("Retry-After", "2.5"),
1432 );
1433 let err = coinbase.ticker(&symbol()).unwrap_err();
1434 let wait = std::time::Duration::from_millis(2500);
1435 assert!(matches!(err, Error::RateLimited { retry_after: Some(d) } if d == wait));
1436 }
1437}