Skip to content

Instantly share code, notes, and snippets.

@rubpy
Created March 16, 2025 22:12
Show Gist options
  • Save rubpy/f2297dd1013cb6cac42aca9fe213403b to your computer and use it in GitHub Desktop.
Save rubpy/f2297dd1013cb6cac42aca9fe213403b to your computer and use it in GitHub Desktop.
<?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