diff --git a/container/src/finance/lib/internal/fetch/exchanges/internal/exchanges/coinbase.php b/container/src/finance/lib/internal/fetch/exchanges/internal/exchanges/coinbase.php index 10e7bf8..258715d 100644 --- a/container/src/finance/lib/internal/fetch/exchanges/internal/exchanges/coinbase.php +++ b/container/src/finance/lib/internal/fetch/exchanges/internal/exchanges/coinbase.php @@ -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 */ - abstract class Coinbase extends Impl + abstract class Accounts extends Impl { public function __construct(utils\Env $env) { parent::__construct($env); } - //! @brief Implements underlying API request - public function request(string $path): mixed + /** + * @brief Get Coinbase Accounts with underlying API + * @return array ccxt fetchAccounts object + */ + private function get(): array { - $response = []; - $timer = 10; - $success = false; - while (!$success) { - 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); - } - } + // Note: given the small size of returned structure, + // there's currently no forseeable need to paginate. + $response = $this->get_api()->fetchAccounts(); // @phpstan-ignore-line + utils\CLI::print_debug($response); return $response; } - /** @return array */ - protected function get_paginated_path(string $path): array - { - $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 + /** + * @brief Get Coinbase Accounts + * @return array ccxt fetchAccounts object + */ 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 * @since docker-finance 1.0.0 */ - final class Transactions extends Coinbase + abstract class Transactions extends Accounts { public function __construct(utils\Env $env) { @@ -145,55 +108,107 @@ namespace docker_finance\exchanges\internal\exchanges\coinbase } /** - * @brief Wrapper for underlying API - * @phpstan-ignore-next-line // TODO: resolve + * @brief Get Coinbase transactions with underlying API + * @param array $account ccxt fetchAccounts object of Coinbase account (e.g., wallet, vault, etc.) + * @param int $since Milliseconds since epoch to begin fetch from + * @return array 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'; - utils\CLI::print_debug($path); + $params = []; - 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 - * @return Single account metadata with attached transactions, prepared for writing - * @phpstan-ignore-next-line // TODO: resolve + * @brief Get Coinbase transactions and prepare for reading + * @param array $account ccxt fetchAccounts object of Coinbase account (e.g., wallet, vault, etc.) + * @return array 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']; - $name = $account['name']; + $name = $account['info']['name']; 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[37;2m $id\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"); - // Account ID + // account_id => tx $stack[] = $id; - // Transactions of account ID - $txs = $this->get_transactions($id); + // Since given timestamp (milliseconds since epoch) + $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) { - // Only given year's tx's. Format: 1970-01-01T12:34:56Z - for ($i = 0; $i < count($txs[$j]['data']); ++$i) { - $created_at = $txs[$j]['data'][$i]['created_at']; - $year = explode('-', $created_at)[0]; - if ($year == $this->get_env()->get_env('API_FETCH_YEAR')) { - $stack[$id][] = $txs[$j]['data'][$i]; + while (true) { + // Transactions of account ID + $txs = Transactions::get($account, $timestamp); + + if (!count($txs)) { + break; + } + + // 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; } + } + + /** + * @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> N accounts with metadata and attached transaction data, prepared for writing - */ public function read(): array { // Fetch accounts (not txs of accounts) @@ -204,30 +219,25 @@ namespace docker_finance\exchanges\internal\exchanges\coinbase $this->get_env()->get_env('API_SUBACCOUNT') ); - // account_id => transactions $stack = []; foreach ($accounts as $account) { - for ($i = 0; $i < count($account['data']); ++$i) { - $data = $account['data'][$i]; - - // Get only applicable symbols/accounts - if (!empty($symbols)) { - $code = $data['currency']['code']; - foreach ($symbols as $symbol) { - if ($symbol == $code) { - $txs = $this->prepare_transactions($data); - if (!empty($txs)) { - array_push($stack, $txs); - } + // Get only applicable symbols/accounts + if (!empty($symbols)) { + $code = $account['code']; + foreach ($symbols as $symbol) { + if ($symbol == $code) { + $txs = $this->get_transactions($account); + if (!empty($txs)) { + array_push($stack, $txs); } } - } else { - // Get all - $txs = $this->prepare_transactions($data); - if (!empty($txs)) { - array_push($stack, $txs); - } + } + } else { + // Get all + $txs = $this->get_transactions($account); + if (!empty($txs)) { + array_push($stack, $txs); } } } @@ -235,23 +245,30 @@ namespace docker_finance\exchanges\internal\exchanges\coinbase return $stack; } - //! @brief Implements write handler public function write(mixed $txs, string $id): void { $file = $this->get_env()->get_env('API_OUT_DIR') . $id . '_transactions.csv'; $this->raw_to_csv($txs, $file); } - //! @brief Implements fetch handler public function fetch(): void { utils\CLI::print_normal(" ─ Account"); foreach ($this->read() as $account => $transactions) { + + // All reads must have 'account_id' attached $id = $transactions[0]; - if (array_key_exists($id, $transactions)) { - $txs = $transactions[$id]; - $this->write($txs, $id); + if (empty($id)) { + utils\CLI::throw_fatal("Missing account 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 { - private coinbase\Transactions $transactions; + private coinbase\Coinbase $api; public function __construct(\docker_finance\utils\Env $env) { - $this->transactions = new coinbase\Transactions($env); + $this->api = new coinbase\Coinbase($env); } public function fetch(): void { - $this->transactions->fetch(); + $this->api->fetch(); } } } // namespace docker_finance\exchanges\internal\exchanges