php: coinbase: use ccxt end-user public-facing API

A recent combination of Coinbase and ccxt changes will now result in
authentication errors (server error 401) because of docker-finance's
coinbase implementation (specifically, direct calls to internal ccxt).

docker-finance's previous usage of request() meant hooking into the
underlying ccxt API and not the end-user public-facing API (publicly
documented - not necessarily the "public" vs "private" APIs provided by
exchanges, as seen in ccxt documentation).

This direct usage of request() was obviously never recommended but it
was always easier to interact with to get to the underlying raw object
(at least at the time of original docker-finance coinbase writing).

Since it appears that ccxt has refactored the authentication mechanism
related to request(), this commit also refactors in order to provide a
working fix for authentication.
This commit is contained in:
2024-07-01 23:16:01 -07:00
parent f64e58de81
commit 2d3ab7857c

View File

@@ -61,74 +61,37 @@ namespace docker_finance\exchanges\internal\exchanges\coinbase
} }
/** /**
* @brief Common implementation * @brief Coinbase Accounts
* @details All available accounts for end-user (e.g., wallets, vaults, etc.)
* @since docker-finance 1.0.0 * @since docker-finance 1.0.0
*/ */
abstract class Coinbase extends Impl abstract class Accounts extends Impl
{ {
public function __construct(utils\Env $env) public function __construct(utils\Env $env)
{ {
parent::__construct($env); parent::__construct($env);
} }
//! @brief Implements underlying API request /**
public function request(string $path): mixed * @brief Get Coinbase Accounts with underlying API
* @return array<mixed> ccxt fetchAccounts object
*/
private function get(): array
{ {
$response = []; // Note: given the small size of returned structure,
$timer = 10; // there's currently no forseeable need to paginate.
$success = false; $response = $this->get_api()->fetchAccounts(); // @phpstan-ignore-line
while (!$success) { utils\CLI::print_debug($response);
try {
utils\CLI::print_debug($path);
$response = $this->get_api()->request($path, 'private', 'GET');
utils\CLI::print_debug($response);
$success = true;
} catch (\ccxt\NetworkError $ex) {
utils\CLI::print_warning(
$ex->getMessage() . " ! Trying again in $timer seconds..."
);
sleep($timer);
}
}
return $response; return $response;
} }
/** @return array<string> */ /**
protected function get_paginated_path(string $path): array * @brief Get Coinbase Accounts
{ * @return array<mixed> ccxt fetchAccounts object
$stack = []; */
// Push first response. Will provide us with pagination if exists
utils\CLI::print_debug($path);
$response = $this->request($path);
array_push($stack, $response);
// Process paginated
$paginated = false;
if (!empty($response['pagination']['next_uri'])) {
$paginated = true;
}
while ($paginated) {
// next_uri now returns whole URI, cut out /v2/
$next = substr($response['pagination']['next_uri'], 4);
$response = $this->request($next);
array_push($stack, $response);
if (empty($response['pagination']['next_uri'])) {
$paginated = false;
}
}
return $stack;
}
// @phpstan-ignore-next-line // TODO: resolve
protected function get_accounts(): array protected function get_accounts(): array
{ {
return $this->get_paginated_path('accounts?&limit=100&order=asc'); return Accounts::get();
} }
} }
@@ -137,7 +100,7 @@ namespace docker_finance\exchanges\internal\exchanges\coinbase
* @details Transfers / Rewards / non-Trades * @details Transfers / Rewards / non-Trades
* @since docker-finance 1.0.0 * @since docker-finance 1.0.0
*/ */
final class Transactions extends Coinbase abstract class Transactions extends Accounts
{ {
public function __construct(utils\Env $env) public function __construct(utils\Env $env)
{ {
@@ -145,55 +108,107 @@ namespace docker_finance\exchanges\internal\exchanges\coinbase
} }
/** /**
* @brief Wrapper for underlying API * @brief Get Coinbase transactions with underlying API
* @phpstan-ignore-next-line // TODO: resolve * @param array<mixed> $account ccxt fetchAccounts object of Coinbase account (e.g., wallet, vault, etc.)
* @param int $since Milliseconds since epoch to begin fetch from
* @return array<mixed> Unpaginated (but with cursor) ledger (transactions) of given account since timestamp
*/ */
private function get_transactions(string $account_id): array private function get(array $account, int $since): array
{ {
$path = 'accounts/' . $account_id . '/transactions?&limit=100&order=asc'; $params = [];
utils\CLI::print_debug($path);
return $this->get_paginated_path($path); // WARNING: must be added in order to discern between wallet and vault
$params['account_id'] = $account['id'];
// Pagination
if (array_key_exists('starting_after', $account)) {
$params['starting_after'] = $account['starting_after'];
}
// @phpstan-ignore-next-line
$response = $this->get_api()->fetchLedger(
$account['code'],
$since,
100,
$params
);
utils\CLI::print_debug($response);
return $response;
} }
/** /**
* @brief Get/parse transactions * @brief Get Coinbase transactions and prepare for reading
* @return Single account metadata with attached transactions, prepared for writing * @param array<mixed> $account ccxt fetchAccounts object of Coinbase account (e.g., wallet, vault, etc.)
* @phpstan-ignore-next-line // TODO: resolve * @return array<mixed> Complete ledger (transactions) of given account for given (environment) year
*/ */
private function prepare_transactions(array $account): array protected function get_transactions(array $account): array
{ {
$id = $account['id']; $id = $account['id'];
$name = $account['name']; $name = $account['info']['name'];
utils\CLI::print_custom(" \e[32m│\e[0m\n"); utils\CLI::print_custom(" \e[32m│\e[0m\n");
utils\CLI::print_custom(" \e[32m├─\e[34m\e[1;3m $name\e[0m\n"); utils\CLI::print_custom(" \e[32m├─\e[34m\e[1;3m {$name}\e[0m\n");
utils\CLI::print_custom(" \e[32m│ └─\e[37;2m $id\e[0m\n"); utils\CLI::print_custom(" \e[32m│ └─\e[37;2m {$id}\e[0m\n");
// Account ID // account_id => tx
$stack[] = $id; $stack[] = $id;
// Transactions of account ID // Since given timestamp (milliseconds since epoch)
$txs = $this->get_transactions($id); $given_year = $this->get_env()->get_env('API_FETCH_YEAR');
$year = new \DateTime($this->get_env()->get_env('API_FETCH_YEAR') . '-01-01');
$timestamp = $year->format('U') * 1000;
for ($j = 0; $j < count($txs); ++$j) { while (true) {
// Only given year's tx's. Format: 1970-01-01T12:34:56Z // Transactions of account ID
for ($i = 0; $i < count($txs[$j]['data']); ++$i) { $txs = Transactions::get($account, $timestamp);
$created_at = $txs[$j]['data'][$i]['created_at'];
$year = explode('-', $created_at)[0]; if (!count($txs)) {
if ($year == $this->get_env()->get_env('API_FETCH_YEAR')) { break;
$stack[$id][] = $txs[$j]['data'][$i]; }
// Keep only given year's tx's
for ($i = 0; $i < count($txs); ++$i) {
$info = $txs[$i]['info'];
// Format: 1970-01-01T12:34:56Z
$created_at = $info['created_at'];
$at_year = explode('-', $created_at)[0];
if ($at_year != $given_year) {
break 2;
} }
$stack[$id][] = $txs[$i];
}
// Paginate (if needed)
$last = $txs[array_key_last($txs)];
if (array_key_exists('next_starting_after', $last)) {
$account['starting_after'] = $last['next_starting_after'];
} else {
break;
} }
} }
sort($stack);
return $stack; return $stack;
} }
}
/**
* @brief Coinbase fetch object
* @details Implements complete fetch operation
* @since docker-finance 1.0.0
*/
final class Coinbase extends Transactions
{
//! @brief No-op
//! @todo Base refactor for exclusive ccxt
public function request(string $path): mixed
{
return [];
}
/**
* @brief Implements read handler
* @return array<array<int, string>> N accounts with metadata and attached transaction data, prepared for writing
*/
public function read(): array public function read(): array
{ {
// Fetch accounts (not txs of accounts) // Fetch accounts (not txs of accounts)
@@ -204,30 +219,25 @@ namespace docker_finance\exchanges\internal\exchanges\coinbase
$this->get_env()->get_env('API_SUBACCOUNT') $this->get_env()->get_env('API_SUBACCOUNT')
); );
// account_id => transactions
$stack = []; $stack = [];
foreach ($accounts as $account) { foreach ($accounts as $account) {
for ($i = 0; $i < count($account['data']); ++$i) { // Get only applicable symbols/accounts
$data = $account['data'][$i]; if (!empty($symbols)) {
$code = $account['code'];
// Get only applicable symbols/accounts foreach ($symbols as $symbol) {
if (!empty($symbols)) { if ($symbol == $code) {
$code = $data['currency']['code']; $txs = $this->get_transactions($account);
foreach ($symbols as $symbol) { if (!empty($txs)) {
if ($symbol == $code) { array_push($stack, $txs);
$txs = $this->prepare_transactions($data);
if (!empty($txs)) {
array_push($stack, $txs);
}
} }
} }
} else { }
// Get all } else {
$txs = $this->prepare_transactions($data); // Get all
if (!empty($txs)) { $txs = $this->get_transactions($account);
array_push($stack, $txs); if (!empty($txs)) {
} array_push($stack, $txs);
} }
} }
} }
@@ -235,23 +245,30 @@ namespace docker_finance\exchanges\internal\exchanges\coinbase
return $stack; return $stack;
} }
//! @brief Implements write handler
public function write(mixed $txs, string $id): void public function write(mixed $txs, string $id): void
{ {
$file = $this->get_env()->get_env('API_OUT_DIR') . $id . '_transactions.csv'; $file = $this->get_env()->get_env('API_OUT_DIR') . $id . '_transactions.csv';
$this->raw_to_csv($txs, $file); $this->raw_to_csv($txs, $file);
} }
//! @brief Implements fetch handler
public function fetch(): void public function fetch(): void
{ {
utils\CLI::print_normal(" ─ Account"); utils\CLI::print_normal(" ─ Account");
foreach ($this->read() as $account => $transactions) { foreach ($this->read() as $account => $transactions) {
// All reads must have 'account_id' attached
$id = $transactions[0]; $id = $transactions[0];
if (array_key_exists($id, $transactions)) { if (empty($id)) {
$txs = $transactions[$id]; utils\CLI::throw_fatal("Missing account ID");
$this->write($txs, $id); }
// Not all reads will have txs (no account activity since timestamp)
if (array_key_exists(1, $transactions)) {
$txs = $transactions[1];
if (!empty($txs)) {
$this->write($txs, $id);
}
} }
} }
@@ -274,16 +291,16 @@ namespace docker_finance\exchanges\internal\exchanges
*/ */
final class Coinbase extends \docker_finance\exchanges\API final class Coinbase extends \docker_finance\exchanges\API
{ {
private coinbase\Transactions $transactions; private coinbase\Coinbase $api;
public function __construct(\docker_finance\utils\Env $env) public function __construct(\docker_finance\utils\Env $env)
{ {
$this->transactions = new coinbase\Transactions($env); $this->api = new coinbase\Coinbase($env);
} }
public function fetch(): void public function fetch(): void
{ {
$this->transactions->fetch(); $this->api->fetch();
} }
} }
} // namespace docker_finance\exchanges\internal\exchanges } // namespace docker_finance\exchanges\internal\exchanges