Created
March 16, 2025 22:12
-
-
Save rubpy/f2297dd1013cb6cac42aca9fe213403b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
// | |
// NOTE: only one dependency is needed: `firebase/php-jwt`. | |
// (install using: `composer require firebase/php-jwt`) | |
// | |
require(dirname(__FILE__) . "/vendor/autoload.php"); | |
//////////////////////////////////////////////////////////////////////////////// | |
interface CbKey { | |
public function sign(string $requestLine, int $expiry): string; | |
} | |
class CbKeyEcDsa256 implements CbKey { | |
protected string $id; | |
protected OpenSSLAsymmetricKey $secretKey; | |
public function __construct(string $id, OpenSSLAsymmetricKey $secretKey) { | |
$this->secretKey = $secretKey; | |
$this->id = $id; | |
} | |
public static function fromPem(string $id, string $pemEncodedPrivateKey): self { | |
$secretKey = openssl_pkey_get_private($pemEncodedPrivateKey); | |
if (!$secretKey) { | |
throw new InvalidArgumentException("Failed to initialize a private key from decoded `pemEncodedPrivateKey`"); | |
} | |
return new static($id, $secretKey); | |
} | |
public function sign(string $requestLine, int $expiry): string { | |
$now = time(); | |
return \Firebase\JWT\JWT::encode([ | |
"sub" => $this->id, | |
"iss" => "cdp", | |
"nbf" => $now, | |
"exp" => $now + max(1, intval($expiry)), | |
"uri" => $requestLine, | |
], $this->secretKey, "ES256", $this->id, [ | |
"typ" => "JWT", | |
"kid" => $this->id, | |
"nonce" => bin2hex(random_bytes(16)), | |
]); | |
} | |
} | |
class CbKeyEd25519 implements CbKey { | |
protected string $id; | |
protected string $base64EncodedPrivateKey; | |
protected function __construct(string $id, string $base64EncodedPrivateKey) { | |
$this->base64EncodedPrivateKey = $base64EncodedPrivateKey; | |
$this->id = $id; | |
} | |
public static function fromBase64(string $id, string $base64EncodedPrivateKey): self { | |
$secretKeyBytes = base64_decode($base64EncodedPrivateKey); | |
if (strlen($secretKeyBytes) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) { | |
throw new InvalidArgumentException( | |
"Invalid length of decoded `base64EncodedPrivateKey`" . | |
" (expected: " . SODIUM_CRYPTO_SIGN_SECRETKEYBYTES . | |
", got: " . strlen($secretKeyBytes) . ")", | |
); | |
} | |
return new static($id, $base64EncodedPrivateKey); | |
} | |
public function sign(string $requestLine, int $expiry): string { | |
$now = time(); | |
return \Firebase\JWT\JWT::encode([ | |
"sub" => $this->id, | |
"iss" => "cdp", | |
"nbf" => $now, | |
"exp" => $now + max(1, intval($expiry)), | |
"uri" => $requestLine, | |
], $this->base64EncodedPrivateKey, "EdDSA", $this->id, [ | |
"typ" => "JWT", | |
"kid" => $this->id, | |
"nonce" => bin2hex(random_bytes(16)), | |
]); | |
} | |
} | |
class CbClient { | |
public const DEFAULT_BASE_URL = "https://api.coinbase.com"; | |
public string $baseUrl = self::DEFAULT_BASE_URL; | |
public int $defaultRequestTimeout = 60; | |
public int $defaultSignatureExpiry = 120; | |
protected ?CbKey $key = null; | |
public function __construct(?CbKey $key = null) { | |
if ($key) { | |
$this->key = $key; | |
} | |
} | |
public function request( | |
string $method, | |
string $uri, | |
array $params = [], | |
mixed $body = null, | |
array $headers = [], | |
bool $sign = true, | |
): array { | |
$parsedUrl = parse_url(rtrim($this->baseUrl, "/") . "/" . ltrim($uri, "/")); | |
$urlPort = $parsedUrl["port"] ?? 0; | |
$urlAuthority = !isset($parsedUrl["host"]) ? "" | |
: ($parsedUrl["host"] . ($urlPort ? ":" . $urlPort : "")); | |
if (isset($parsedUrl["query"])) { | |
$opaqueParams = []; | |
parse_str($parsedUrl["query"], $opaqueParams); | |
if (is_array($opaqueParams) && !empty($opaqueParams)) { | |
foreach ($opaqueParams as $k => $v) { | |
if (!isset($params[$k])) $params[$k] = $v; | |
} | |
} | |
} | |
$parsedUrl["query"] = !empty($params) ? http_build_query($params) : ""; | |
$method = is_string($method) ? strtoupper($method) : "GET"; | |
if ($sign) { | |
if (!$this->key) { | |
throw new Exception("Client request could not be signed due to missing `key`"); | |
} | |
$requestLine = $method . " " . $urlAuthority . ($parsedUrl["path"] ?? ""); | |
$signature = ""; | |
try { | |
$signature = $this->key->sign($requestLine, $this->defaultSignatureExpiry); | |
} catch (\Exception $e) { | |
throw new Exception("Client request could not be signed due to internal failure", 0, $e); | |
} | |
if (!is_string($signature) || empty($signature)) { | |
throw new Exception("Client request could not be signed due to unexpected internal failure"); | |
} | |
$headers["Authorization"] = "Bearer $signature"; | |
} | |
$rawBody = $body === null ? "" : (is_string($body) ? $body : (json_encode($body) ?: "")); | |
if (!empty($rawBody) && !isset($headers["Content-Type"])) { | |
$headers["Content-Type"] = "application/json"; | |
} | |
$url = | |
(isset($parsedUrl["scheme"]) ? $parsedUrl["scheme"] . ":" : "") . | |
(!empty($urlAuthority) ? "//" . $urlAuthority : "") . | |
(isset($parsedUrl["path"]) ? $parsedUrl["path"] : "") . | |
(!empty($parsedUrl["query"]) ? "?" . $parsedUrl["query"] : ""); | |
$rawResponse = $this->rawRequest($method, $url, $rawBody, $headers); | |
$response = []; | |
try { | |
$response = json_decode($rawResponse, true); | |
} catch (\Exception $e) { | |
throw new Exception("Unexpected response format", 0, $e); | |
} | |
return is_array($response) ? $response : []; | |
} | |
protected function rawRequest( | |
string $method, | |
string $url, | |
?string $body, | |
array $headers = [], | |
array $options = [], | |
): string { | |
$ch = curl_init(); | |
curl_setopt_array($ch, [ | |
CURLOPT_URL => $url, | |
CURLOPT_RETURNTRANSFER => true, | |
CURLOPT_FOLLOWLOCATION => true, | |
CURLOPT_MAXREDIRS => 3, | |
CURLOPT_TIMEOUT => (isset($options["timeout"]) ? max(0, intval($options["timeout"])) : $this->defaultRequestTimeout), | |
CURLOPT_CUSTOMREQUEST => $method, | |
CURLOPT_POSTFIELDS => $body, | |
CURLOPT_HTTPHEADER => $headers ? | |
array_map(fn($k, $v): string => "$k: $v", array_keys($headers), array_values($headers)) | |
: [], | |
]); | |
$response = curl_exec($ch) ?: ""; | |
$status = intval(curl_getinfo($ch, CURLINFO_HTTP_CODE)); | |
if (!($status >= 200 && $status <= 299)) { | |
throw new Exception("Erroneous response status code: " . $status); | |
} | |
curl_close($ch); | |
return $response; | |
} | |
} | |
//////////////////////////////////////////////////////////////////////////////// | |
function main() { | |
// // 🔑 Example: using an Ed25519-based API key. | |
// $client = new CbClient( | |
// CbKeyEd25519::fromBase64( | |
// "00000000-0000-0000-0000-000000000000", | |
// "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==", | |
// ), | |
// ); | |
// | |
// // 🔑 Example: using an ECDSA-based API key. | |
// $client = new CbClient( | |
// CbKeyEcDsa256::fromPem( | |
// "organizations/00000000-0000-0000-0000-000000000000/apiKeys/00000000-0000-0000-0000-000000000000", | |
// "-----BEGIN EC PRIVATE KEY-----\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==\n-----END EC PRIVATE KEY-----\n", | |
// ), | |
// ); | |
// 🔓 Example: unauthenticated client. | |
$client = new CbClient(); | |
////////////////////////////////////////////////////////////////////////////// | |
// 🪪 Example: "List Accounts". | |
try { | |
$response = $client->request("GET", "/api/v3/brokerage/accounts"); | |
echo json_encode($response) . "\n\n"; | |
} catch (\Exception $e) { | |
echo "[!] Request `···/accounts` has failed.\n ⇾ ERROR: " . $e->getMessage() . "\n\n"; | |
} | |
// 📊 Example: "Get Public Product Candles". | |
try { | |
$now = time(); | |
$response = $client->request( | |
"GET", | |
"/api/v3/brokerage/market/products/BTC-USD/candles", | |
[ | |
"start" => ($now - (60 * 60)), | |
"end" => $now, | |
"granularity" => "ONE_MINUTE", | |
"limit" => 5, | |
], | |
sign: false, // NOTE: explicitly setting `sign` to false (not strictly necessary). | |
); | |
echo json_encode($response) . "\n\n"; | |
} catch (\Exception $e) { | |
echo "[!] Request `···/candles` has failed.\n ⇾ ERROR: " . $e->getMessage() . "\n\n"; | |
} | |
} | |
main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment