Files
sure/app/models/provider/coinstats.rb
Anas Limouri a90f9b7317 Add CoinStats exchange portfolio sync and normalize linked investment charts (#1308)
* [FEATURE] Add CoinStats exchange portfolios and normalize linked investment charts

* [BUGFIX] Fix CoinStats PR regressions

* [BUGFIX] Fix CoinStats PR review findings

* [BUGFIX] Address follow-up CoinStats PR feedback

* [REFACTO] Extract CoinStats exchange account helpers

* [BUGFIX] Batch linked CoinStats chart normalization

* [BUGFIX] Fix CoinStats processor lint

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-04-01 20:25:06 +02:00

483 lines
17 KiB
Ruby

# API client for CoinStats cryptocurrency data provider.
# Handles authentication and requests to the CoinStats OpenAPI.
class Provider::Coinstats < Provider
include HTTParty
extend SslConfigurable
# Subclass so errors caught in this provider are raised as Provider::Coinstats::Error
Error = Class.new(Provider::Error)
BASE_URL = "https://openapiv1.coinstats.app"
headers "User-Agent" => "Sure Finance CoinStats Client (https://github.com/we-promise/sure)"
default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options))
attr_reader :api_key
# @param api_key [String] CoinStats API key for authentication
def initialize(api_key)
@api_key = api_key
end
# Get the list of blockchains supported by CoinStats
# https://coinstats.app/api-docs/openapi/get-blockchains
def get_blockchains
with_provider_response do
res = self.class.get("#{BASE_URL}/wallet/blockchains", headers: auth_headers)
handle_response(res)
end
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "CoinStats API: GET /wallet/blockchains failed: #{e.class}: #{e.message}"
raise Error, "CoinStats API request failed: #{e.message}"
end
# Returns blockchain options formatted for select dropdowns
# @return [Array<Array>] Array of [label, value] pairs sorted alphabetically
def blockchain_options
response = get_blockchains
unless response.success?
Rails.logger.warn("CoinStats: failed to fetch blockchains: #{response.error&.message}")
return []
end
raw_blockchains = response.data
items = if raw_blockchains.is_a?(Array)
raw_blockchains
elsif raw_blockchains.respond_to?(:dig) && raw_blockchains[:data].is_a?(Array)
raw_blockchains[:data]
else
[]
end
items.filter_map do |b|
b = b.with_indifferent_access
value = b[:connectionId] || b[:id] || b[:name]
next unless value.present?
label = b[:name].presence || value.to_s
[ label, value ]
end.uniq { |_label, value| value }.sort_by { |label, _| label.to_s.downcase }
rescue StandardError => e
Rails.logger.warn("CoinStats: failed to fetch blockchains: #{e.class} - #{e.message}")
[]
end
# Get the list of exchange connections supported by CoinStats
# https://coinstats.app/api-docs/openapi/get-exchanges
def get_exchanges
with_provider_response do
res = self.class.get("#{BASE_URL}/exchange/support", headers: auth_headers)
handle_response(res)
end
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "CoinStats API: GET /exchange/support failed: #{e.class}: #{e.message}"
raise Error, "CoinStats API request failed: #{e.message}"
end
def exchange_options
response = get_exchanges
unless response.success?
Rails.logger.warn("CoinStats: failed to fetch exchanges: #{response.error&.message}")
return []
end
Array(response.data).filter_map do |exchange|
exchange = exchange.with_indifferent_access
connection_id = exchange[:connectionId]
next unless connection_id.present?
{
connection_id: connection_id.to_s,
name: exchange[:name].presence || connection_id.to_s.titleize,
icon: exchange[:icon],
connection_fields: Array(exchange[:connectionFields]).map do |field|
field = field.with_indifferent_access
{
key: field[:key].to_s,
name: field[:name].presence || field[:key].to_s.humanize
}
end
}
end.sort_by { |exchange| exchange[:name].to_s.downcase }
rescue StandardError => e
Rails.logger.warn("CoinStats: failed to fetch exchanges: #{e.class} - #{e.message}")
[]
end
# Connect an exchange portfolio and return its portfolio id
# https://coinstats.app/api-docs/openapi/connect-portfolio-exchange
def connect_portfolio_exchange(connection_id:, connection_fields:, name: nil)
with_provider_response do
res = self.class.post(
"#{BASE_URL}/portfolio/exchange",
headers: auth_headers.merge("Content-Type" => "application/json"),
body: {
connectionId: connection_id,
connectionFields: connection_fields,
name: name
}.compact.to_json
)
handle_response(res)
end
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "CoinStats API: POST /portfolio/exchange failed: #{e.class}: #{e.message}"
raise Error, "CoinStats API request failed: #{e.message}"
end
# Get all holdings for a CoinStats portfolio.
# https://coinstats.app/api-docs/openapi/get-portfolio-coins
def get_portfolio_coins(portfolio_id:, page: 1, limit: 100)
with_provider_response do
res = self.class.get(
"#{BASE_URL}/portfolio/coins",
headers: auth_headers,
query: {
portfolioId: portfolio_id,
page: page,
limit: limit
}
)
handle_response(res)
end
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "CoinStats API: GET /portfolio/coins failed: #{e.class}: #{e.message}"
raise Error, "CoinStats API request failed: #{e.message}"
end
def list_portfolio_coins(portfolio_id:, limit: 100)
page = 1
results = []
loop do
response = get_portfolio_coins(portfolio_id: portfolio_id, page: page, limit: limit)
raise response.error unless response.success?
payload = response.data.with_indifferent_access
page_results = Array(payload[:result])
results.concat(page_results)
break if page_results.size < limit
page += 1
end
results
end
# Get all transactions for a CoinStats portfolio.
# https://coinstats.app/api-docs/openapi/get-portfolio-transactions
def get_portfolio_transactions(portfolio_id:, currency: "USD", page: 1, limit: 100, from: nil, to: nil, coin_id: nil)
with_provider_response do
res = self.class.get(
"#{BASE_URL}/portfolio/transactions",
headers: auth_headers,
query: {
portfolioId: portfolio_id,
currency: currency,
page: page,
limit: limit,
from: from,
to: to,
coinId: coin_id
}.compact
)
handle_response(res)
end
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "CoinStats API: GET /portfolio/transactions failed: #{e.class}: #{e.message}"
raise Error, "CoinStats API request failed: #{e.message}"
end
def list_portfolio_transactions(portfolio_id:, currency: "USD", limit: 100, from: nil, to: nil)
page = 1
results = []
loop do
response = get_portfolio_transactions(
portfolio_id: portfolio_id,
currency: currency,
page: page,
limit: limit,
from: from,
to: to
)
raise response.error unless response.success?
payload = response.data.with_indifferent_access
page_results = Array(payload[:data] || payload[:result])
results.concat(page_results)
break if page_results.size < limit
page += 1
end
results
end
# Get transaction data for a specific exchange portfolio.
# https://coinstats.app/api-docs/openapi/get-exchange-transactions
def get_exchange_transactions(portfolio_id:, currency: "USD", page: 1, limit: 100, from: nil, to: nil)
with_provider_response do
res = self.class.get(
"#{BASE_URL}/exchange/transactions",
headers: auth_headers,
query: {
portfolioId: portfolio_id,
currency: currency,
page: page,
limit: limit,
from: from,
to: to
}.compact
)
handle_response(res)
end
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "CoinStats API: GET /exchange/transactions failed: #{e.class}: #{e.message}"
raise Error, "CoinStats API request failed: #{e.message}"
end
def list_exchange_transactions(portfolio_id:, currency: "USD", limit: 100, from: nil, to: nil)
page = 1
results = []
loop do
response = get_exchange_transactions(
portfolio_id: portfolio_id,
currency: currency,
page: page,
limit: limit,
from: from,
to: to
)
raise response.error unless response.success?
payload = response.data.with_indifferent_access
page_results = Array(payload[:result] || payload[:data])
results.concat(page_results)
break if page_results.size < limit
page += 1
end
results
end
# Trigger a fresh CoinStats sync for the portfolio.
# https://coinstats.app/api-docs/openapi/sync-portfolio
def sync_portfolio(portfolio_id:)
with_provider_response do
res = self.class.patch(
"#{BASE_URL}/portfolio/sync",
headers: auth_headers,
query: { portfolioId: portfolio_id }
)
handle_response(res)
end
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "CoinStats API: PATCH /portfolio/sync failed: #{e.class}: #{e.message}"
raise Error, "CoinStats API request failed: #{e.message}"
end
# Trigger a fresh CoinStats exchange sync for the portfolio.
# https://coinstats.app/api-docs/openapi/exchange-sync-status
def sync_exchange(portfolio_id:)
with_provider_response do
res = self.class.patch(
"#{BASE_URL}/exchange/sync",
headers: auth_headers,
query: { portfolioId: portfolio_id }
)
handle_response(res)
end
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "CoinStats API: PATCH /exchange/sync failed: #{e.class}: #{e.message}"
raise Error, "CoinStats API request failed: #{e.message}"
end
# Get current sync status for the portfolio.
# https://coinstats.app/api-docs/openapi/get-portfolio-sync-status
def get_portfolio_sync_status(portfolio_id:)
with_provider_response do
res = self.class.get(
"#{BASE_URL}/portfolio/status",
headers: auth_headers,
query: { portfolioId: portfolio_id }
)
handle_response(res)
end
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "CoinStats API: GET /portfolio/status failed: #{e.class}: #{e.message}"
raise Error, "CoinStats API request failed: #{e.message}"
end
# Get cryptocurrency balances for multiple wallets in a single request
# https://coinstats.app/api-docs/openapi/get-wallet-balances
# @param wallets [String] Comma-separated list of wallet addresses in format "blockchain:address"
# Example: "ethereum:0x123abc,bitcoin:bc1qxyz"
# @return [Provider::Response] Response with wallet balance data
def get_wallet_balances(wallets)
return with_provider_response { [] } if wallets.blank?
with_provider_response do
res = self.class.get(
"#{BASE_URL}/wallet/balances",
headers: auth_headers,
query: { wallets: wallets }
)
handle_response(res)
end
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "CoinStats API: GET /wallet/balances failed: #{e.class}: #{e.message}"
raise Error, "CoinStats API request failed: #{e.message}"
end
# Extract balance data for a specific wallet from bulk response
# @param bulk_data [Array<Hash>] Response from get_wallet_balances
# @param address [String] Wallet address to find
# @param blockchain [String] Blockchain/connectionId to find
# @return [Array<Hash>] Token balances for the wallet, or empty array if not found
def extract_wallet_balance(bulk_data, address, blockchain)
return [] unless bulk_data.is_a?(Array)
wallet_data = bulk_data.find do |entry|
entry = entry.with_indifferent_access
entry[:address]&.downcase == address&.downcase &&
(entry[:connectionId]&.downcase == blockchain&.downcase ||
entry[:blockchain]&.downcase == blockchain&.downcase)
end
return [] unless wallet_data
wallet_data = wallet_data.with_indifferent_access
wallet_data[:balances] || []
end
# Get transaction data for multiple wallet addresses in a single request
# https://coinstats.app/api-docs/openapi/get-wallet-transactions
# @param wallets [String] Comma-separated list of wallet addresses in format "blockchain:address"
# Example: "ethereum:0x123abc,bitcoin:bc1qxyz"
# @return [Provider::Response] Response with wallet transaction data
def get_wallet_transactions(wallets)
return with_provider_response { [] } if wallets.blank?
with_provider_response do
res = self.class.get(
"#{BASE_URL}/wallet/transactions",
headers: auth_headers,
query: { wallets: wallets }
)
handle_response(res)
end
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "CoinStats API: GET /wallet/transactions failed: #{e.class}: #{e.message}"
raise Error, "CoinStats API request failed: #{e.message}"
end
# Extract transaction data for a specific wallet from bulk response
# The transactions API returns {result: Array<transactions>, meta: {...}}
# All transactions in the response belong to the requested wallets
# @param bulk_data [Hash, Array] Response from get_wallet_transactions
# @param address [String] Wallet address to filter by (currently unused as API returns flat list)
# @param blockchain [String] Blockchain/connectionId to filter by (currently unused)
# @return [Array<Hash>] Transactions for the wallet, or empty array if not found
def extract_wallet_transactions(bulk_data, address, blockchain)
# Handle Hash response with :result key (current API format)
if bulk_data.is_a?(Hash)
bulk_data = bulk_data.with_indifferent_access
return bulk_data[:result] || []
end
# Handle legacy Array format (per-wallet structure)
return [] unless bulk_data.is_a?(Array)
wallet_data = bulk_data.find do |entry|
entry = entry.with_indifferent_access
entry[:address]&.downcase == address&.downcase &&
(entry[:connectionId]&.downcase == blockchain&.downcase ||
entry[:blockchain]&.downcase == blockchain&.downcase)
end
return [] unless wallet_data
wallet_data = wallet_data.with_indifferent_access
wallet_data[:transactions] || []
end
private
def auth_headers
{
"X-API-KEY" => api_key,
"Accept" => "application/json"
}
end
# The CoinStats API uses standard HTTP status codes to indicate the success or failure of requests.
# https://coinstats.app/api-docs/errors
def handle_response(response)
case response.code
when 200
JSON.parse(response.body, symbolize_names: true)
when 400
log_api_error(response, "Bad Request")
raise_api_error(response, fallback: "CoinStats: Invalid request parameters")
when 401
log_api_error(response, "Unauthorized")
raise_api_error(response, fallback: "CoinStats: Invalid or missing API key")
when 403
log_api_error(response, "Forbidden")
raise_api_error(response, fallback: "CoinStats: Access denied")
when 404
log_api_error(response, "Not Found")
raise_api_error(response, fallback: "CoinStats: Resource not found")
when 409
log_api_error(response, "Conflict")
raise_api_error(response, fallback: "CoinStats: Resource conflict")
when 429
log_api_error(response, "Too Many Requests")
raise_api_error(response, fallback: "CoinStats: Rate limit exceeded, try again later")
when 500
log_api_error(response, "Internal Server Error")
raise_api_error(response, fallback: "CoinStats: Server error, try again later")
when 503
log_api_error(response, "Service Unavailable")
raise_api_error(response, fallback: "CoinStats: Service temporarily unavailable")
else
log_api_error(response, "Unexpected Error")
raise_api_error(response, fallback: "CoinStats: An unexpected error occurred")
end
end
def log_api_error(response, error_type)
Rails.logger.error "CoinStats API: #{response.code} #{error_type} - #{response.body}"
end
def raise_api_error(response, fallback:)
error_payload = parse_error_payload(response.body)
message = error_payload[:message].presence || fallback
request_id = error_payload[:request_id].presence
message = "#{message} (requestId: #{request_id})" if request_id.present?
raise Error.new(message, details: error_payload.compact.presence)
end
def parse_error_payload(body)
payload = JSON.parse(body.presence || "{}", symbolize_names: true)
{
status_code: payload[:statusCode] || payload[:status_code],
message: payload[:message],
request_id: payload[:requestId] || payload[:request_id],
path: payload[:path]
}
rescue JSON::ParserError
{}
end
end