Files
sure/app/models/provider/coinbase.rb
LPW dd991fa339 Add Coinbase exchange integration with CDP API support (#704)
* **Add Coinbase integration with item and account management**
- Creates migrations for `coinbase_items` and `coinbase_accounts`.
- Adds models, controllers, views, and background tasks to support account linking, syncing, and transaction handling.
- Implements Coinbase API client and adapter for seamless integration.
- Supports ActiveRecord encryption for secure credential storage.
- Adds UI components for provider setup, account management, and synchronization.

* Localize Coinbase-related UI strings, refine account linking for security, and add timeouts to Coinbase API requests.

* Localize Coinbase account handling to support native currencies (USD, EUR, GBP, etc.) across balances, trades, holdings, and transactions.

* Improve Coinbase processing with timezone-safe parsing, native currency support, and immediate holdings updates.

* Improve trend percentage formatting and enhance race condition handling for Coinbase account linking.

* Fix log message wording for orphan cleanup

* Ensure `selected_accounts` parameter is sanitized by rejecting blank entries.

* Add tests for Coinbase integration: account, item, and controller coverage

- Adds unit tests for `CoinbaseAccount` and `CoinbaseItem` models.
- Adds integration tests for `CoinbaseItemsController`.
- Introduces Stimulus `select-all` controller for UI checkbox handling.
- Localizes UI strings and logging for Coinbase integration.

* Update test fixtures to use consistent placeholder API keys and secrets

* Refine `coinbase_item` tests to ensure deterministic ordering and improve scope assertions.

* Integrate `SyncStats::Collector` into Coinbase syncer to streamline statistics collection and enhance consistency.

* Localize Coinbase sync status messages and improve sync summary test coverage.

* Update `CoinbaseItem` encryption: use deterministic encryption for `api_key` and standard for `api_secret`.

* fix schema drift

* Beta labels to lower expectations

---------

Co-authored-by: luckyPipewrench <luckypipewrench@proton.me>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
2026-01-21 22:56:39 +01:00

201 lines
5.4 KiB
Ruby

class Provider::Coinbase
class Error < StandardError; end
class AuthenticationError < Error; end
class RateLimitError < Error; end
class ApiError < Error; end
# CDP API base URL
API_BASE_URL = "https://api.coinbase.com".freeze
attr_reader :api_key, :api_secret
def initialize(api_key:, api_secret:)
@api_key = api_key
@api_secret = api_secret
end
# Get current user info
def get_user
get("/v2/user")["data"]
end
# Get all accounts (wallets)
def get_accounts
paginated_get("/v2/accounts")
end
# Get single account details
def get_account(account_id)
get("/v2/accounts/#{account_id}")["data"]
end
# Get transactions for an account
def get_transactions(account_id, limit: 100)
paginated_get("/v2/accounts/#{account_id}/transactions", limit: limit)
end
# Get buy transactions for an account
def get_buys(account_id, limit: 100)
paginated_get("/v2/accounts/#{account_id}/buys", limit: limit)
end
# Get sell transactions for an account
def get_sells(account_id, limit: 100)
paginated_get("/v2/accounts/#{account_id}/sells", limit: limit)
end
# Get deposits for an account
def get_deposits(account_id, limit: 100)
paginated_get("/v2/accounts/#{account_id}/deposits", limit: limit)
end
# Get withdrawals for an account
def get_withdrawals(account_id, limit: 100)
paginated_get("/v2/accounts/#{account_id}/withdrawals", limit: limit)
end
# Get spot price for a currency pair (e.g., "BTC-USD")
# This is a public endpoint that doesn't require authentication
def get_spot_price(currency_pair)
response = HTTParty.get("#{API_BASE_URL}/v2/prices/#{currency_pair}/spot", timeout: 10)
handle_response(response)["data"]
rescue => e
Rails.logger.warn("Coinbase: Failed to fetch spot price for #{currency_pair}: #{e.message}")
nil
end
# Get spot prices for multiple currencies in USD
# Returns hash like { "BTC" => 92520.90, "ETH" => 3200.50 }
def get_spot_prices(currencies)
prices = {}
currencies.each do |currency|
result = get_spot_price("#{currency}-USD")
prices[currency] = result["amount"].to_d if result && result["amount"]
end
prices
end
private
def get(path, params: {})
url = "#{API_BASE_URL}#{path}"
url += "?#{params.to_query}" if params.any?
response = HTTParty.get(
url,
headers: auth_headers("GET", path),
timeout: 30
)
handle_response(response)
end
def paginated_get(path, limit: 100)
results = []
next_uri = nil
fetched = 0
loop do
if next_uri
# Parse the next_uri to get just the path
uri = URI.parse(next_uri)
current_path = uri.path
current_path += "?#{uri.query}" if uri.query
url = "#{API_BASE_URL}#{current_path}"
else
current_path = path
url = "#{API_BASE_URL}#{path}"
end
response = HTTParty.get(
url,
headers: auth_headers("GET", current_path.split("?").first),
timeout: 30
)
data = handle_response(response)
results.concat(data["data"] || [])
fetched += (data["data"] || []).size
break if fetched >= limit
break unless data.dig("pagination", "next_uri")
next_uri = data.dig("pagination", "next_uri")
end
results.first(limit)
end
# Generate JWT token for CDP API authentication
# Uses Ed25519 signing algorithm
def generate_jwt(method, path)
# Decode the base64 private key
private_key_bytes = Base64.decode64(api_secret)
# Create Ed25519 signing key
signing_key = Ed25519::SigningKey.new(private_key_bytes[0, 32])
now = Time.now.to_i
uri = "#{method} api.coinbase.com#{path}"
# JWT header
header = {
alg: "EdDSA",
kid: api_key,
nonce: SecureRandom.hex(16),
typ: "JWT"
}
# JWT payload
payload = {
sub: api_key,
iss: "cdp",
nbf: now,
exp: now + 120,
uri: uri
}
# Encode header and payload
encoded_header = Base64.urlsafe_encode64(header.to_json, padding: false)
encoded_payload = Base64.urlsafe_encode64(payload.to_json, padding: false)
# Sign
message = "#{encoded_header}.#{encoded_payload}"
signature = signing_key.sign(message)
encoded_signature = Base64.urlsafe_encode64(signature, padding: false)
"#{message}.#{encoded_signature}"
end
def auth_headers(method, path)
{
"Authorization" => "Bearer #{generate_jwt(method, path)}",
"Content-Type" => "application/json"
}
end
def handle_response(response)
parsed = response.parsed_response
case response.code
when 200..299
parsed.is_a?(Hash) ? parsed : { "data" => parsed }
when 401
error_msg = extract_error_message(parsed) || "Unauthorized - check your API key and secret"
raise AuthenticationError, error_msg
when 429
raise RateLimitError, "Rate limit exceeded"
else
error_msg = extract_error_message(parsed) || "API error: #{response.code}"
raise ApiError, error_msg
end
end
def extract_error_message(parsed)
return parsed if parsed.is_a?(String)
return nil unless parsed.is_a?(Hash)
parsed.dig("errors", 0, "message") || parsed["error"] || parsed["message"]
end
end