Packages

Elixir client library for cryptocurrency exchanges — generated from CCXT specs via compile-time macros.

Current section

Files

Jump to
ccxt_client lib ccxt signing classifier.ex
Raw

lib/ccxt/signing/classifier.ex

defmodule CCXT.Signing.Classifier do
@moduledoc """
Classifies exchange signing patterns from spec AST data.
Analyzes `structure.sign_method` AST from each exchange spec to determine
which of the 9 signing patterns it uses, plus exchange-specific config
(header names, encoding preferences).
## Classification Strategy
1. **Header-name matching** (primary) — distinctive header strings in the AST
uniquely identify patterns (e.g., `X-BAPI-*` → Bybit-style headers)
2. **Hash algorithm fallback** — sha384/sha512/sha256 in AST identifiers
3. **Parent inheritance** — child exchanges (e.g., binancecoinm) inherit
from their parent's pattern
4. **`:custom` fallback** — unclassifiable exchanges get the escape hatch
## Usage
spec = CCXT.Spec.load!("bybit")
{pattern, config} = CCXT.Signing.Classifier.classify(spec)
#=> {:hmac_sha256_headers, %{api_key_header: "X-BAPI-API-KEY", ...}}
"""
@type classification :: {CCXT.Signing.pattern(), config()}
@type config :: %{optional(atom()) => term()}
# Child exchanges that inherit signing from a parent.
# These have no `structure.sign_method` in their spec.
@parent_map %{
"binancecoinm" => "binance",
"binanceus" => "binance",
"binanceusdm" => "binance",
"bequant" => "hitbtc",
"coinbaseadvanced" => "coinbase",
"exchange_v1" => "hitbtc",
"fmfwio" => "hitbtc",
"gateio" => "gate",
"huobi" => "htx",
"kucoinfutures" => "kucoin",
"myokx" => "okx",
"okxus" => "okx"
}
@doc """
Classifies the signing pattern for an exchange spec.
Returns `{pattern_atom, config_map}` where config contains
exchange-specific header names and encoding preferences.
If the spec has no `sign_method`, falls back to parent inheritance
or returns `{:custom, %{}}`.
"""
@spec classify(map()) :: classification()
def classify(spec) do
exchange_id = get_in(spec, ["exchange", "id"])
sign_method = get_in(spec, ["structure", "sign_method"])
case sign_method do
nil -> classify_no_sign_method(exchange_id)
ast -> classify_from_ast(ast)
end
end
@doc """
Classifies a signing pattern from a raw sign_method AST map.
Useful when you have the AST directly without a full spec wrapper.
"""
@spec classify_from_ast(map()) :: classification()
def classify_from_ast(ast) do
text = Jason.encode!(ast)
literals = extract_literals(text)
idents = extract_idents(text)
{pattern, base_config} = match_pattern(literals, idents)
config = extract_config(literals, pattern, base_config)
{pattern, config}
end
@doc """
Returns the parent exchange ID for a child exchange, or nil.
"""
@spec parent_for(String.t()) :: String.t() | nil
def parent_for(exchange_id), do: Map.get(@parent_map, exchange_id)
@doc """
Returns all known parent→child mappings.
"""
@spec parent_map() :: %{String.t() => String.t()}
def parent_map, do: @parent_map
# --- Classification for exchanges without sign_method ---
defp classify_no_sign_method(exchange_id) do
case Map.get(@parent_map, exchange_id) do
nil ->
{:custom, %{}}
parent_id ->
parent_spec = CCXT.Spec.load!(parent_id)
classify(parent_spec)
end
end
# --- Pattern matching (two-tier heuristic) ---
# Rules are evaluated in order — first match wins.
# Each rule is {pattern, check_fn}. Separated from cond to keep complexity low.
defp match_pattern(literals, idents) do
rules = header_rules() ++ algorithm_rules()
result =
Enum.find_value(rules, fn {pattern, check} ->
if check.(literals, idents), do: {pattern, %{}}
end)
result || {:custom, %{}}
end
# Tier 1: Header-name patterns (most reliable — unique per exchange family)
defp header_rules do
[
{:deribit, fn lits, _ids -> has_literal_prefix?(lits, "deri-hmac") end},
{:hmac_sha256_passphrase_signed, fn lits, _ids -> has_literal_prefix?(lits, "KC-API-") end},
{:hmac_sha256_iso_passphrase, fn lits, _ids -> has_literal_prefix?(lits, "OK-ACCESS-") end},
{:hmac_sha384_payload, fn lits, _ids -> has_literal?(lits, "bfx-apikey") or has_literal?(lits, "bfx-nonce") end},
{:hmac_sha512_gate, fn _lits, ids -> has_ident?(ids, "signaturePath") end},
{:hmac_sha512_nonce, fn lits, _ids -> has_literal?(lits, "API-Key") and has_literal?(lits, "API-Sign") end},
{:hmac_sha256_query, fn lits, _ids -> has_literal?(lits, "X-MBX-APIKEY") end},
{:hmac_sha256_headers, fn lits, _ids -> has_literal_prefix?(lits, "X-BAPI-") end},
{:hmac_sha256_iso_passphrase,
fn lits, _ids ->
has_literal_prefix?(lits, "ACCESS-KEY") and has_literal_prefix?(lits, "ACCESS-SIGN") and
has_literal_prefix?(lits, "ACCESS-PASSPHRASE")
end}
]
end
# Tier 2: Hash algorithm + structural cues (fallback)
defp algorithm_rules do
[
{:hmac_sha384_payload, fn _lits, ids -> has_ident?(ids, "sha384") end},
{:hmac_sha512_nonce, fn _lits, ids -> has_ident?(ids, "sha512") and has_ident?(ids, "sha256") end},
{:hmac_sha512_nonce, fn _lits, ids -> has_ident?(ids, "sha512") end},
{:hmac_sha256_iso_passphrase,
fn lits, ids ->
(has_ident?(ids, "passphrase") or has_passphrase_literal?(lits)) and has_ident?(ids, "sha256")
end},
{:hmac_sha256_query, fn lits, ids -> has_signature_in_query?(lits) and has_ident?(ids, "sha256") end},
{:hmac_sha256_headers, fn _lits, ids -> has_ident?(ids, "sha256") end}
]
end
# --- Config extraction ---
# Extracts exchange-specific signing config (header names) from AST literals.
# Pattern modules have sensible defaults, so we only need overrides.
defp extract_config(literals, pattern, base_config) do
headers = extract_header_literals(literals)
config = extract_pattern_config(pattern, headers)
Map.merge(base_config, config)
end
defp extract_pattern_config(:hmac_sha256_headers, headers), do: extract_headers_config(headers)
defp extract_pattern_config(:hmac_sha256_query, headers), do: extract_query_config(headers)
defp extract_pattern_config(:hmac_sha256_iso_passphrase, h), do: extract_iso_config(h)
defp extract_pattern_config(:hmac_sha256_passphrase_signed, h), do: extract_kucoin_config(h)
defp extract_pattern_config(:hmac_sha512_nonce, headers), do: extract_nonce_config(headers)
defp extract_pattern_config(:hmac_sha512_gate, headers), do: extract_gate_config(headers)
defp extract_pattern_config(:hmac_sha384_payload, headers), do: extract_payload_config(headers)
defp extract_pattern_config(_pattern, _headers), do: %{}
# Extracts config for HMAC-SHA256 headers pattern (Bybit-style)
defp extract_headers_config(headers) do
%{}
|> maybe_put(:api_key_header, find_header(headers, ["apikey", "api-key", "api_key"]))
|> maybe_put(:timestamp_header, find_header(headers, ["timestamp", "time"]))
|> maybe_put(:signature_header, find_header(headers, ["sign", "signature", "hmac"]))
|> maybe_put(:recv_window_header, find_header(headers, ["recv-window", "recvwindow"]))
end
# Extracts config for HMAC-SHA256 query pattern (Binance-style)
defp extract_query_config(headers) do
maybe_put(%{}, :api_key_header, find_header(headers, ["apikey", "api-key", "api_key"]))
end
# Extracts config for ISO passphrase pattern (OKX-style)
defp extract_iso_config(headers) do
%{}
|> maybe_put(:api_key_header, find_header(headers, ["key", "apikey", "api-key"]))
|> maybe_put(:timestamp_header, find_header(headers, ["timestamp", "time"]))
|> maybe_put(:signature_header, find_header(headers, ["sign", "signature"]))
|> maybe_put(:passphrase_header, find_header(headers, ["passphrase"]))
end
# KuCoin passphrase-signed pattern uses same header layout as ISO passphrase (OKX)
defp extract_kucoin_config(headers), do: extract_iso_config(headers)
# Extracts config for HMAC-SHA512 nonce pattern (Kraken-style)
defp extract_nonce_config(headers) do
%{}
|> maybe_put(:api_key_header, find_header(headers, ["key", "apikey", "api-key"]))
|> maybe_put(:signature_header, find_header(headers, ["sign", "signature", "authent", "hmac"]))
end
# Extracts config for Gate.io pattern
defp extract_gate_config(headers) do
%{}
|> maybe_put(:api_key_header, find_header(headers, ["key"]))
|> maybe_put(:timestamp_header, find_header(headers, ["timestamp"]))
|> maybe_put(:signature_header, find_header(headers, ["sign"]))
end
# Extracts config for HMAC-SHA384 payload pattern (Bitfinex-style)
defp extract_payload_config(headers) do
payload_header = find_header(headers, ["payload"])
variant = if payload_header, do: :gemini, else: :bitfinex
base = %{
variant: variant,
api_key_header: find_header(headers, ["apikey", "api-key", "key"]),
signature_header: find_header(headers, ["sign", "signature"])
}
variant_specific =
case variant do
:gemini -> %{payload_header: payload_header}
:bitfinex -> %{nonce_header: find_header(headers, ["nonce"])}
end
base
|> Map.merge(variant_specific)
|> Map.reject(fn {_k, v} -> is_nil(v) end)
end
# --- AST text scanning helpers ---
# Extracts all string literal values from JSON-serialized AST
defp extract_literals(text) do
~r/"value":\s*"([^"]+)"/
|> Regex.scan(text)
|> MapSet.new(fn [_, value] -> value end)
end
# Extracts all identifier names from JSON-serialized AST
defp extract_idents(text) do
~r/"name":\s*"([^"]+)"/
|> Regex.scan(text)
|> MapSet.new(fn [_, name] -> name end)
end
# Extracts header-like string literals (contain dash, mixed case, short, not URLs)
defp extract_header_literals(literals) do
literals
|> Enum.filter(fn lit ->
String.length(lit) < 40 and
not String.contains?(lit, "http") and
not String.contains?(lit, "endpoint") and
not String.contains?(lit, " ") and
(String.contains?(lit, "-") or lit in ~w(KEY SIGN Timestamp nonce sign timestamp signature))
end)
|> MapSet.new()
end
defp has_literal?(literals, value), do: MapSet.member?(literals, value)
defp has_literal_prefix?(literals, prefix) do
Enum.any?(literals, &String.starts_with?(&1, prefix))
end
defp has_ident?(idents, name), do: MapSet.member?(idents, name)
defp has_signature_in_query?(literals) do
Enum.any?(literals, fn lit ->
String.contains?(lit, "signature=") or String.contains?(lit, "&sign=")
end)
end
# Checks if any literal contains "passphrase" (case-insensitive).
# Catches exchanges like apex (APEX-PASSPHRASE), coinbase (CB-ACCESS-PASSPHRASE)
# where passphrase appears in header names but not as an AST identifier.
defp has_passphrase_literal?(literals) do
Enum.any?(literals, fn lit ->
String.contains?(String.downcase(lit), "passphrase")
end)
end
# Finds the first header literal matching any of the search terms (case-insensitive).
# Sorts first for deterministic results — MapSet iteration order is not guaranteed.
defp find_header(headers, search_terms) do
headers
|> Enum.sort()
|> Enum.find(fn header ->
header_lower = String.downcase(header)
Enum.any?(search_terms, fn term ->
String.contains?(header_lower, term)
end)
end)
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
end