Files
sure/app/models/provider/coinbase.rb
BitToby ba6e286b41 feat: add SSL_CA_FILE and SSL_VERIFY environment variables to support… (#894)
* feat: add SSL_CA_FILE and SSL_VERIFY environment variables to support self-signed certificates in self-hosted environments

* fix: NoMethodError by defining SSL helper methods before configure block executes

* refactor: Refactor SessionsController to use shared SslConfigurable module and simplify SSL initializer redundant checks

* refactor: improve SSL configuration robustness and error detection accuracy

* fix:HTTParty SSL options, add file validation guards, prevent Tempfile GC, and redact URLs in error logs

* fix:  Fix SSL concern indentation and stub Simplefin POST correctly in tests

* fix: normalize ssl_verify to always return boolean instead of nil

* fix: solve failing SimpleFin test

* refactor:  trim unused error-handling code from SslConfigurable, replace Tempfile with fixed-path CA bundle, fix namespace pollution in initializers, and add unit tests for core SSL configuration and Langfuse CRL callback.

* fix: added require ileutils in the initializer and require ostruct in the test file.

* fix: solve autoload conflict that broke provider loading, validate all certs in PEM bundles, and add missing requires.
2026-02-06 18:04:03 +01:00

206 lines
5.6 KiB
Ruby

class Provider::Coinbase
include HTTParty
extend SslConfigurable
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
base_uri API_BASE_URL
default_options.merge!({ timeout: 30 }.merge(httparty_ssl_options))
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)
# Use self.class.get to inherit class-level SSL and timeout defaults
response = self.class.get("/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 = path
url += "?#{params.to_query}" if params.any?
# Use self.class.get to inherit class-level SSL and timeout defaults
response = self.class.get(
url,
headers: auth_headers("GET", path)
)
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
else
current_path = path
end
# Use self.class.get to inherit class-level SSL and timeout defaults
response = self.class.get(
current_path,
headers: auth_headers("GET", current_path.split("?").first)
)
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