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