Skip to content

Commit

Permalink
Add support for v3 Coinbase API (home-assistant#116345)
Browse files Browse the repository at this point in the history
* Add support for v3 Coinbase API

* Add deps

* Move tests
  • Loading branch information
TomBrien authored Aug 8, 2024
1 parent d08f4fb commit baceb2a
Show file tree
Hide file tree
Showing 11 changed files with 359 additions and 96 deletions.
103 changes: 84 additions & 19 deletions homeassistant/components/coinbase/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
from datetime import timedelta
import logging

from coinbase.wallet.client import Client
from coinbase.rest import RESTClient
from coinbase.rest.rest_base import HTTPError
from coinbase.wallet.client import Client as LegacyClient
from coinbase.wallet.error import AuthenticationError

from homeassistant.config_entries import ConfigEntry
Expand All @@ -15,8 +17,23 @@
from homeassistant.util import Throttle

from .const import (
ACCOUNT_IS_VAULT,
API_ACCOUNT_AMOUNT,
API_ACCOUNT_AVALIABLE,
API_ACCOUNT_BALANCE,
API_ACCOUNT_CURRENCY,
API_ACCOUNT_CURRENCY_CODE,
API_ACCOUNT_HOLD,
API_ACCOUNT_ID,
API_ACCOUNTS_DATA,
API_ACCOUNT_NAME,
API_ACCOUNT_VALUE,
API_ACCOUNTS,
API_DATA,
API_RATES_CURRENCY,
API_RESOURCE_TYPE,
API_TYPE_VAULT,
API_V3_ACCOUNT_ID,
API_V3_TYPE_VAULT,
CONF_CURRENCIES,
CONF_EXCHANGE_BASE,
CONF_EXCHANGE_RATES,
Expand Down Expand Up @@ -56,9 +73,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

def create_and_update_instance(entry: ConfigEntry) -> CoinbaseData:
"""Create and update a Coinbase Data instance."""
client = Client(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN])
if "organizations" not in entry.data[CONF_API_KEY]:
client = LegacyClient(entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN])
version = "v2"
else:
client = RESTClient(
api_key=entry.data[CONF_API_KEY], api_secret=entry.data[CONF_API_TOKEN]
)
version = "v3"
base_rate = entry.options.get(CONF_EXCHANGE_BASE, "USD")
instance = CoinbaseData(client, base_rate)
instance = CoinbaseData(client, base_rate, version)
instance.update()
return instance

Expand All @@ -83,42 +107,83 @@ async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> Non
registry.async_remove(entity.entity_id)


def get_accounts(client):
def get_accounts(client, version):
"""Handle paginated accounts."""
response = client.get_accounts()
accounts = response[API_ACCOUNTS_DATA]
next_starting_after = response.pagination.next_starting_after

while next_starting_after:
response = client.get_accounts(starting_after=next_starting_after)
accounts += response[API_ACCOUNTS_DATA]
if version == "v2":
accounts = response[API_DATA]
next_starting_after = response.pagination.next_starting_after

return accounts
while next_starting_after:
response = client.get_accounts(starting_after=next_starting_after)
accounts += response[API_DATA]
next_starting_after = response.pagination.next_starting_after

return [
{
API_ACCOUNT_ID: account[API_ACCOUNT_ID],
API_ACCOUNT_NAME: account[API_ACCOUNT_NAME],
API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY][
API_ACCOUNT_CURRENCY_CODE
],
API_ACCOUNT_AMOUNT: account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT],
ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_TYPE_VAULT,
}
for account in accounts
]

accounts = response[API_ACCOUNTS]
while response["has_next"]:
response = client.get_accounts(cursor=response["cursor"])
accounts += response["accounts"]

return [
{
API_ACCOUNT_ID: account[API_V3_ACCOUNT_ID],
API_ACCOUNT_NAME: account[API_ACCOUNT_NAME],
API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY],
API_ACCOUNT_AMOUNT: account[API_ACCOUNT_AVALIABLE][API_ACCOUNT_VALUE]
+ account[API_ACCOUNT_HOLD][API_ACCOUNT_VALUE],
ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_V3_TYPE_VAULT,
}
for account in accounts
]


class CoinbaseData:
"""Get the latest data and update the states."""

def __init__(self, client, exchange_base):
def __init__(self, client, exchange_base, version):
"""Init the coinbase data object."""

self.client = client
self.accounts = None
self.exchange_base = exchange_base
self.exchange_rates = None
self.user_id = self.client.get_current_user()[API_ACCOUNT_ID]
if version == "v2":
self.user_id = self.client.get_current_user()[API_ACCOUNT_ID]
else:
self.user_id = (
"v3_" + client.get_portfolios()["portfolios"][0][API_V3_ACCOUNT_ID]
)
self.api_version = version

@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Get the latest data from coinbase."""

try:
self.accounts = get_accounts(self.client)
self.exchange_rates = self.client.get_exchange_rates(
currency=self.exchange_base
)
except AuthenticationError as coinbase_error:
self.accounts = get_accounts(self.client, self.api_version)
if self.api_version == "v2":
self.exchange_rates = self.client.get_exchange_rates(
currency=self.exchange_base
)
else:
self.exchange_rates = self.client.get(
"/v2/exchange-rates",
params={API_RATES_CURRENCY: self.exchange_base},
)[API_DATA]
except (AuthenticationError, HTTPError) as coinbase_error:
_LOGGER.error(
"Authentication error connecting to coinbase: %s", coinbase_error
)
45 changes: 29 additions & 16 deletions homeassistant/components/coinbase/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
import logging
from typing import Any

from coinbase.wallet.client import Client
from coinbase.rest import RESTClient
from coinbase.rest.rest_base import HTTPError
from coinbase.wallet.client import Client as LegacyClient
from coinbase.wallet.error import AuthenticationError
import voluptuous as vol

Expand All @@ -15,18 +17,17 @@
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_API_VERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv

from . import get_accounts
from .const import (
ACCOUNT_IS_VAULT,
API_ACCOUNT_CURRENCY,
API_ACCOUNT_CURRENCY_CODE,
API_DATA,
API_RATES,
API_RESOURCE_TYPE,
API_TYPE_VAULT,
CONF_CURRENCIES,
CONF_EXCHANGE_BASE,
CONF_EXCHANGE_PRECISION,
Expand All @@ -49,8 +50,11 @@

def get_user_from_client(api_key, api_token):
"""Get the user name from Coinbase API credentials."""
client = Client(api_key, api_token)
return client.get_current_user()
if "organizations" not in api_key:
client = LegacyClient(api_key, api_token)
return client.get_current_user()["name"]
client = RESTClient(api_key=api_key, api_secret=api_token)
return client.get_portfolios()["portfolios"][0]["name"]


async def validate_api(hass: HomeAssistant, data):
Expand All @@ -60,11 +64,13 @@ async def validate_api(hass: HomeAssistant, data):
user = await hass.async_add_executor_job(
get_user_from_client, data[CONF_API_KEY], data[CONF_API_TOKEN]
)
except AuthenticationError as error:
if "api key" in str(error):
except (AuthenticationError, HTTPError) as error:
if "api key" in str(error) or " 401 Client Error" in str(error):
_LOGGER.debug("Coinbase rejected API credentials due to an invalid API key")
raise InvalidKey from error
if "invalid signature" in str(error):
if "invalid signature" in str(
error
) or "'Could not deserialize key data" in str(error):
_LOGGER.debug(
"Coinbase rejected API credentials due to an invalid API secret"
)
Expand All @@ -73,23 +79,29 @@ async def validate_api(hass: HomeAssistant, data):
raise InvalidAuth from error
except ConnectionError as error:
raise CannotConnect from error

return {"title": user["name"]}
api_version = "v3" if "organizations" in data[CONF_API_KEY] else "v2"
return {"title": user, "api_version": api_version}


async def validate_options(hass: HomeAssistant, config_entry: ConfigEntry, options):
"""Validate the requested resources are provided by API."""

client = hass.data[DOMAIN][config_entry.entry_id].client

accounts = await hass.async_add_executor_job(get_accounts, client)
accounts = await hass.async_add_executor_job(
get_accounts, client, config_entry.data.get("api_version", "v2")
)

accounts_currencies = [
account[API_ACCOUNT_CURRENCY][API_ACCOUNT_CURRENCY_CODE]
account[API_ACCOUNT_CURRENCY]
for account in accounts
if account[API_RESOURCE_TYPE] != API_TYPE_VAULT
if not account[ACCOUNT_IS_VAULT]
]
available_rates = await hass.async_add_executor_job(client.get_exchange_rates)
if config_entry.data.get("api_version", "v2") == "v2":
available_rates = await hass.async_add_executor_job(client.get_exchange_rates)
else:
resp = await hass.async_add_executor_job(client.get, "/v2/exchange-rates")
available_rates = resp[API_DATA]
if CONF_CURRENCIES in options:
for currency in options[CONF_CURRENCIES]:
if currency not in accounts_currencies:
Expand Down Expand Up @@ -134,6 +146,7 @@ async def async_step_user(
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
user_input[CONF_API_VERSION] = info["api_version"]
return self.async_create_entry(title=info["title"], data=user_input)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
Expand Down
11 changes: 10 additions & 1 deletion homeassistant/components/coinbase/const.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Constants used for Coinbase."""

ACCOUNT_IS_VAULT = "is_vault"

CONF_CURRENCIES = "account_balance_currencies"
CONF_EXCHANGE_BASE = "exchange_base"
CONF_EXCHANGE_RATES = "exchange_rate_currencies"
Expand All @@ -10,18 +12,25 @@

# Constants for data returned by Coinbase API
API_ACCOUNT_AMOUNT = "amount"
API_ACCOUNT_AVALIABLE = "available_balance"
API_ACCOUNT_BALANCE = "balance"
API_ACCOUNT_CURRENCY = "currency"
API_ACCOUNT_CURRENCY_CODE = "code"
API_ACCOUNT_HOLD = "hold"
API_ACCOUNT_ID = "id"
API_ACCOUNT_NATIVE_BALANCE = "balance"
API_ACCOUNT_NAME = "name"
API_ACCOUNTS_DATA = "data"
API_ACCOUNT_VALUE = "value"
API_ACCOUNTS = "accounts"
API_DATA = "data"
API_RATES = "rates"
API_RATES_CURRENCY = "currency"
API_RESOURCE_PATH = "resource_path"
API_RESOURCE_TYPE = "type"
API_TYPE_VAULT = "vault"
API_USD = "USD"
API_V3_ACCOUNT_ID = "uuid"
API_V3_TYPE_VAULT = "ACCOUNT_TYPE_VAULT"

WALLETS = {
"1INCH": "1INCH",
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/coinbase/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/coinbase",
"iot_class": "cloud_polling",
"loggers": ["coinbase"],
"requirements": ["coinbase==2.1.0"]
"requirements": ["coinbase==2.1.0", "coinbase-advanced-py==1.2.2"]
}
Loading

0 comments on commit baceb2a

Please sign in to comment.