Skip to main content

wickra_exchange_core/exchanges/
coinbase.rs

1//! Coinbase Advanced Trade — the ninth exchange.
2//!
3//! Coinbase authenticates every request with a short-lived **ES256 JWT**: the
4//! header carries `alg=ES256`, the API key name as `kid`, and a nonce; the
5//! payload carries `sub`, `iss=cdp`, `nbf`/`exp` and a `uri` of
6//! `"METHOD host path"`. The JWT is signed with the account's EC private key
7//! (`Credentials::with_private_key`, PKCS#8 PEM) via P-256 ECDSA — which uses
8//! deterministic RFC-6979 nonces, so a test can verify the signature against the
9//! derived public key. Symbols are dash-form (`BTC-USD`); errors come back as an
10//! HTTP error status with an `{error, message}` body.
11
12use 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
48/// A Coinbase Advanced Trade client over injected transports.
49pub struct Coinbase {
50    http: Box<dyn HttpTransport>,
51    ws: Option<Box<dyn WsTransport>>,
52    rest_base: String,
53    /// The market this client was built for.
54    ///
55    /// This venue serves spot only, and the market was previously dropped on the
56    /// floor: a client built for futures answered spot questions with spot data
57    /// and said nothing.
58    market_type: MarketType,
59    credentials: Option<Credentials>,
60    now_ms: Box<dyn Fn() -> i64 + Send + Sync>,
61    /// Client order ids for orders the caller did not name.
62    ///
63    /// Seeded from the clock at construction and monotonic from there: the
64    /// seed keeps two clients in one process apart, the counter keeps two
65    /// orders in one millisecond apart. The previous fallback was the
66    /// millisecond alone, so a second order placed inside the same
67    /// millisecond carried an id the venue had already seen and was refused
68    /// as a duplicate.
69    client_ids: ClientIdGenerator,
70    /// Offset between this machine's clock and the venue's, applied to every
71    /// signed timestamp. Zero until [`sync_time`](Self::sync_time) is called.
72    clock: ServerClock,
73    connection: Option<Box<dyn WsConnection>>,
74    sub_messages: Vec<String>,
75}
76
77/// Hand-written: the client holds `Box<dyn HttpTransport>`, `Box<dyn WsTransport>`
78/// and a `Box<dyn Fn() -> i64>` clock, none of which a derive can reach. The
79/// transports are shown by whether a connection is open rather than by value, and
80/// the credentials only by whether they are set -- printing them would put secret
81/// material into every log line that formats a client.
82impl 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    /// Refuse a market this client does not route.
97    ///
98    /// Called at every seam that reaches the venue -- the HTTP helpers and each
99    /// WebSocket connect -- so an unrouted market is refused before a request
100    /// is built rather than answered by whichever market the URL happened to
101    /// name. See [`ensure_market_is_routed`](super::ensure_market_is_routed)
102    /// for what each client used to answer instead.
103    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    /// The timestamp a signed request must carry: this machine's clock plus the
130    /// offset learned from the venue.
131    ///
132    /// A venue rejects a signed request whose timestamp falls outside its own
133    /// receive window, so a machine a few seconds off has every order refused --
134    /// with a message about the window rather than about the clock. Until
135    /// [`sync_time`](Self::sync_time) is called the offset is zero and this is
136    /// the local time, which is the previous behaviour.
137    fn signed_now_ms(&self) -> i64 {
138        self.clock.server_time_ms((self.now_ms)())
139    }
140
141    /// Learn the offset between this machine's clock and the venue's, from
142    /// `GET /api/v3/brokerage/time`.
143    ///
144    /// Explicit rather than automatic: it costs a request, and a client should
145    /// not make one the caller did not ask for. Call it once after connecting,
146    /// and again if the process runs long enough for drift to matter.
147    ///
148    /// Returns the new offset in milliseconds (`server - local`).
149    ///
150    /// # Errors
151    /// Returns an [`Error`] if the request fails or the response cannot be parsed.
152    pub fn sync_time(&mut self) -> Result<i64> {
153        self.ensure_market_is_routed()?;
154        // Every other endpoint on this client is signed; the time endpoint is
155        // public, so the request is built here rather than through signed_get.
156        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    /// Build a Coinbase client. Signed calls require credentials whose key name
173    /// is the API key id and whose private key (`with_private_key`) is the EC
174    /// PKCS#8 PEM.
175    #[must_use]
176    pub fn with_http(http: Box<dyn HttpTransport>, options: &ExchangeOptions) -> Self {
177        Self::build(http, options, None)
178    }
179
180    /// Build an authenticated Coinbase client.
181    #[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    /// Override the timestamp source (deterministic signing in tests).
191    #[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    /// Attach a WebSocket transport.
198    #[must_use]
199    pub fn with_ws(mut self, ws: Box<dyn WsTransport>) -> Self {
200        self.ws = Some(ws);
201        self
202    }
203
204    /// The Coinbase wire symbol for a canonical [`Symbol`] (`BTC/USD` -> `BTC-USD`).
205    #[must_use]
206    pub fn wire_symbol(symbol: &Symbol) -> String {
207        format!("{}-{}", symbol.base(), symbol.quote())
208    }
209
210    /// A ticker for `symbol`.
211    ///
212    /// # Errors
213    /// Returns an [`Error`] if the request fails or the symbol is unknown.
214    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            // Advanced Trade's product ticker carries no timestamp.
234            timestamp: 0,
235        })
236    }
237
238    /// Up to `limit` candles for `symbol` at `interval` (unified). Coinbase
239    /// returns newest-first; the result is chronological.
240    ///
241    /// # Errors
242    /// Returns an [`Error`] if the request fails or a row cannot be parsed.
243    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    /// A depth snapshot of `symbol` up to `depth` levels per side.
266    ///
267    /// # Errors
268    /// Returns an [`Error`] if the request fails or the response cannot be parsed.
269    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    /// Subscribe to the public trade stream for `symbol`.
285    ///
286    /// # Errors
287    /// Returns [`Error::NotConnected`] if no WebSocket transport is configured,
288    /// or [`Error::InvalidCredentials`] if credentials are missing.
289    pub fn subscribe_trades(&mut self, symbol: &Symbol) -> Result<()> {
290        self.subscribe(symbol, "market_trades")
291    }
292
293    /// Subscribe to the order-book stream for `symbol`.
294    ///
295    /// # Errors
296    /// See [`subscribe_trades`](Self::subscribe_trades).
297    pub fn subscribe_book(&mut self, symbol: &Symbol) -> Result<()> {
298        self.subscribe(symbol, "level2")
299    }
300
301    /// Subscribe to the ticker stream for `symbol`.
302    ///
303    /// # Errors
304    /// See [`subscribe_trades`](Self::subscribe_trades).
305    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    /// Drain all stream events available since the last call. Non-blocking.
332    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    /// Place an order. Coinbase requires a client order id; one is generated from
353    /// the clock when absent.
354    ///
355    /// # Errors
356    /// Returns an [`Error`] if the order is invalid, credentials are missing, or
357    /// the venue rejects it.
358    /// Coinbase Advanced Trade sets self-trade prevention per *portfolio*, not
359    /// per order, so a policy the caller set cannot be honoured on this request
360    /// and the order is refused instead of placed without it.
361    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    /// Refuse `reduce_only` where there is no position to reduce.
373    ///
374    /// A spot account holds balances, not positions, so a reduce-only order has
375    /// nothing to close. Dropping the flag would place an opening order where a
376    /// closing one was asked for -- the opposite trade -- so it is refused, the
377    /// way Binance's spot path and Upbit already refuse it.
378    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    /// The `order_configuration` for a trigger order.
390    ///
391    /// Coinbase has one trigger configuration, `stop_limit_stop_limit_gtc`, and
392    /// it is a **stop-limit**: there is no stop-market. A `StopMarket` is
393    /// refused rather than sent as a stop-limit at some invented price -- a
394    /// limit chosen here would decide, on the caller's behalf, how much
395    /// slippage their stop may take.
396    ///
397    /// `stop_direction` is not inferred by the venue. `STOP_DIRECTION_STOP_DOWN`
398    /// fires when the market falls to the trigger, which is what protects a
399    /// long; `STOP_DIRECTION_STOP_UP` fires on the way up, which covers a short.
400    /// The wrong one arms the order on the side that never comes.
401    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        // Coinbase names the time-in-force inside the `order_configuration` key
438        // itself -- `market_market_ioc`, `limit_limit_gtc`, `limit_limit_fok` --
439        // so the key is chosen from the order type and the time-in-force
440        // together, and a pairing the venue has no key for is refused.
441        // A trigger is its own configuration key, chosen before the plain
442        // ones: falling through to `market_market_ioc` would place the order
443        // now, which is the whole failure a stop exists to avoid.
444        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    /// Send an order and read back what the venue made of it.
501    ///
502    /// Shared by the plain and the trigger branch: two copies of this body
503    /// would be two places to drift, which is the defect this repository has
504    /// found once per layer.
505    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    /// Cancel an open order by venue id.
554    ///
555    /// # Errors
556    /// Returns an [`Error`] if credentials are missing or the venue rejects it.
557    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    /// Query a single order by venue id.
564    ///
565    /// # Errors
566    /// Returns an [`Error`] if credentials are missing or the order is unknown.
567    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    /// Open orders, optionally filtered to one `symbol`.
577    ///
578    /// # Errors
579    /// Returns an [`Error`] if credentials are missing or the request fails.
580    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    /// Account balances.
607    ///
608    /// # Errors
609    /// Returns an [`Error`] if credentials are missing or the request fails.
610    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    /// Build an ES256 JWT (optionally with a `uri` claim for REST requests).
635    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
801/// Read a nested `{value, currency}` amount (Coinbase balances) as a string.
802fn 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
811/// Order-book / pricebook levels as `{price, size}` objects.
812fn 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    // A fixed P-256 PKCS#8 test key (not a real credential).
1021    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    /// Coinbase names the trigger in the configuration key, and does not infer
1057    /// which way the market must cross it.
1058    ///
1059    /// `STOP_DIRECTION_STOP_DOWN` fires when the market falls to the trigger,
1060    /// which is what protects a long; `..._UP` fires on the way up, which
1061    /// covers a short. The wrong one arms the order on the side that never
1062    /// comes, and nothing reports an error because the order was accepted.
1063    #[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    /// Coinbase has no stop-*market*, and this client will not invent the limit
1094    /// price one would need.
1095    ///
1096    /// Choosing a limit here would decide, on the caller's behalf, how much
1097    /// slippage their stop may take -- which is the number a stop-market exists
1098    /// to leave open.
1099    #[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        // Header + payload decode and carry the expected claims.
1145        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        // The signature verifies against the public key derived from the test key.
1158        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    /// Coinbase names the time-in-force in the `order_configuration` key, and
1169    /// `limit_limit_fok` carries no post-only flag; `post_only` belongs to the
1170    /// limit configurations, so it cannot ride on a market order either.
1171    #[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    /// `Debug` reports connection state, never secret material. A client is
1360    /// formatted into logs and error messages, so anything it prints is
1361    /// somewhere a credential must not be.
1362    #[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        // The PKCS#8 private key is the secret here.
1370        assert!(!rendered.contains("BEGIN PRIVATE KEY"));
1371        assert!(!rendered.contains("MIGHAgEAMBMGByqGSM49"));
1372        assert!(!rendered.contains("organizations/x/apiKeys/y"));
1373    }
1374
1375    /// Coinbase carries the timestamp inside the JWT rather than in a header, so
1376    /// the assertion decodes the token: `nbf` must be the venue's time.
1377    #[test]
1378    fn sync_time_shifts_the_jwt() {
1379        let (mut coinbase, http) = signed_client(1_000_000);
1380
1381        // Shape verified against the live public endpoint on 2026-09-01:
1382        // {"iso":"…","epochSeconds":"…","epochMillis":"1788295968224"}
1383        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    /// A rate-limited response carries the venue's advised wait.
1417    ///
1418    /// The limit itself is recognised from the body -- a code in the error
1419    /// envelope -- while the wait arrives in the `Retry-After` header, so the
1420    /// two are read in different places. Until they were joined, every
1421    /// `RateLimited` this client raised carried `retry_after: None`: a field
1422    /// the error type documents, and that nothing in the crate ever filled.
1423    #[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}