whisper-money/app/Services/Banking/CoinbaseClient.php

196 lines
5.7 KiB
PHP

<?php
namespace App\Services\Banking;
use Exception;
use Firebase\JWT\JWT;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CoinbaseClient
{
private const BASE_URL = 'https://api.coinbase.com';
private const HOST = 'api.coinbase.com';
/** @var array<int, int> 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<string, mixed>
*/
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<int, array<string, mixed>>
*/
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<string, mixed>
*
* @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<int, string> $productIds e.g. ['BTC-EUR', 'ETH-EUR']
* @return array<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed> $params
* @return array<string, mixed>
*/
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(),
]);
});
}
}