mirror of
https://github.com/we-promise/sure.git
synced 2026-04-16 18:44:13 +00:00
* **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>
201 lines
5.4 KiB
Ruby
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
|