Retry backoff: 10s, 30s, 60s */ private const RETRY_BACKOFF_MS = [10_000, 30_000, 60_000]; private const JWT_TTL_SECONDS = 120; /** * @param string $keyName CDP API key name (organizations/{org}/apiKeys/{id}) or Ed25519 key ID (UUID). * @param string $privateKey PEM EC private key (ES256) or base64 Ed25519 secret. */ public function __construct( private string $keyName, private string $privateKey, ) {} /** * ES256 (ECDSA PEM) when keyName is the org/apiKeys path; EdDSA (Ed25519) when keyName is a UUID. */ private function algorithm(): string { return str_starts_with($this->keyName, 'organizations/') ? 'ES256' : 'EdDSA'; } /** * List all brokerage accounts (one per currency) with paginated cursor. * * @return array */ public function getAccounts(?string $cursor = null, int $limit = 250): array { $params = ['limit' => $limit]; if ($cursor !== null && $cursor !== '') { $params['cursor'] = $cursor; } return $this->signedRequest('GET', '/api/v3/brokerage/accounts', $params); } /** * Fetch every account by paginating through the cursor. * * @return array> */ public function getAllAccounts(): array { $all = []; $cursor = null; do { $response = $this->getAccounts($cursor); $batch = $response['accounts'] ?? []; foreach ($batch as $account) { $all[] = $account; } $cursor = $response['has_next'] ?? false ? ($response['cursor'] ?? null) : null; } while ($cursor); return $all; } /** * Get the latest spot price for a single product (e.g. BTC-EUR). * * @return array * * @api */ public function getProduct(string $productId): array { return $this->signedRequest('GET', "/api/v3/brokerage/products/{$productId}"); } /** * Get best bid/ask for multiple product IDs in one request. * * @param array $productIds e.g. ['BTC-EUR', 'ETH-EUR'] * @return array */ public function getBestBidAsk(array $productIds): array { $params = []; foreach ($productIds as $productId) { $params['product_ids'][] = $productId; } return $this->signedRequest('GET', '/api/v3/brokerage/best_bid_ask', $params); } /** * Get historical candles for a product. * * @return array */ public function getProductCandles(string $productId, int $start, int $end, string $granularity = 'ONE_DAY'): array { return $this->signedRequest('GET', "/api/v3/brokerage/products/{$productId}/candles", [ 'start' => $start, 'end' => $end, 'granularity' => $granularity, ]); } /** * Execute a signed JWT request with retry on rate limiting. * * @param array $params * @return array */ private function signedRequest(string $method, string $path, array $params = []): array { return retry( self::RETRY_BACKOFF_MS, function () use ($method, $path, $params) { $jwt = $this->buildJwt($method, $path); $request = $this->client($jwt); $url = $path; if (! empty($params)) { $query = http_build_query($params, '', '&', PHP_QUERY_RFC3986); // Coinbase expects repeated keys without numeric indices: product_ids=A&product_ids=B. $query = preg_replace('/%5B\d+%5D=/', '=', $query); $url .= '?'.$query; } $response = match (strtoupper($method)) { 'GET' => $request->get($url), default => throw new \InvalidArgumentException("Unsupported HTTP method: {$method}"), }; $response->throw(); return $response->json(); }, when: fn (Exception $e) => $e instanceof RequestException && $e->response->status() === 429, ); } /** * Build a Coinbase CDP JWT for a single request. * * Claims: sub=keyName, iss=cdp, nbf=now, exp=now+120, uri="METHOD host/path". * Header includes kid=keyName and a random nonce. */ private function buildJwt(string $method, string $path): string { $now = time(); $payload = [ 'sub' => $this->keyName, 'iss' => 'cdp', 'nbf' => $now, 'exp' => $now + self::JWT_TTL_SECONDS, 'uri' => strtoupper($method).' '.self::HOST.$path, ]; $headers = ['nonce' => bin2hex(random_bytes(16))]; return JWT::encode($payload, $this->privateKey, $this->algorithm(), $this->keyName, $headers); } private function client(string $jwt): PendingRequest { return Http::baseUrl(self::BASE_URL) ->withToken($jwt) ->acceptJson() ->throw(function ($response) { Log::error('Coinbase API error', [ 'status' => $response->status(), 'body' => $response->json(), ]); }); } }