defmodule CCXT.Signing do @moduledoc """ Signing pattern library for exchange authentication. Provides a unified interface for signing API requests across 100+ cryptocurrency exchanges. Instead of per-exchange signing code, 9 parameterized patterns cover 95%+ of all exchanges: | Pattern | Exchanges | Description | |---------|-----------|-------------| | `:hmac_sha256_query` | ~40 | Binance-style: sign query string | | `:hmac_sha256_headers` | ~30 | Bybit-style: sign body, headers | | `:hmac_sha256_iso_passphrase` | ~10 | OKX-style: ISO timestamp + passphrase | | `:hmac_sha256_passphrase_signed` | ~3 | KuCoin-style: HMAC-signed passphrase | | `:hmac_sha512_nonce` | ~3 | Kraken-style: SHA512 + nonce + base64 secret | | `:hmac_sha512_gate` | 1 | Gate.io-style: SHA512 + newline payload | | `:hmac_sha384_payload` | ~3 | Bitfinex-style: payload signing | | `:deribit` | 1 | Custom Authorization header format | | `:custom` | <5% | Escape hatch for edge cases | ## Usage signed = CCXT.Signing.sign( :hmac_sha256_headers, %{method: :get, path: "/v5/account/wallet-balance", body: nil, params: %{}}, credentials, signing_config ) ## Custom Signing Patterns Implement `CCXT.Signing.Behaviour` and use the `:custom` pattern: defmodule MyApp.Signing.MyExchange do @behaviour CCXT.Signing.Behaviour @impl true def sign(request, credentials, config) do %{url: request.path, method: request.method, headers: [], body: request.body} end end See `CCXT.Signing.Behaviour` for the full contract. """ alias CCXT.Credentials alias CCXT.Signing.Custom alias CCXT.Signing.Deribit alias CCXT.Signing.HmacSha256Headers alias CCXT.Signing.HmacSha256Iso alias CCXT.Signing.HmacSha256Kucoin alias CCXT.Signing.HmacSha256Query alias CCXT.Signing.HmacSha384Payload alias CCXT.Signing.HmacSha512Gate alias CCXT.Signing.HmacSha512Nonce @type method :: :get | :post | :put | :delete @type request :: %{ method: method(), path: String.t(), body: String.t() | nil, params: map() } @type signed_request :: %{ url: String.t(), method: method(), headers: [{String.t(), String.t()}], body: String.t() | nil } @type pattern :: :hmac_sha256_query | :hmac_sha256_headers | :hmac_sha256_iso_passphrase | :hmac_sha256_passphrase_signed | :hmac_sha512_nonce | :hmac_sha512_gate | :hmac_sha384_payload | :deribit | :custom @type config :: %{ optional(:api_key_header) => String.t(), optional(:timestamp_header) => String.t(), optional(:signature_header) => String.t(), optional(:passphrase_header) => String.t(), optional(:recv_window_header) => String.t(), optional(:recv_window) => non_neg_integer(), optional(:signature_encoding) => :hex | :base64, optional(:custom_module) => module(), optional(atom()) => term() } # --- Dispatcher (function-head routing) --- @doc """ Signs a request using the specified pattern and configuration. ## Parameters - `pattern` - The signing pattern atom (e.g., `:hmac_sha256_headers`) - `request` - Map with `:method`, `:path`, `:body`, and `:params` - `credentials` - `CCXT.Credentials` struct with API key and secret - `config` - Pattern-specific configuration from the exchange spec ## Returns A signed request map with `:url`, `:method`, `:headers`, and `:body`. """ @spec sign(pattern(), request(), Credentials.t(), config()) :: signed_request() def sign(:hmac_sha256_query, request, credentials, config) do HmacSha256Query.sign(request, credentials, config) end def sign(:hmac_sha256_headers, request, credentials, config) do HmacSha256Headers.sign(request, credentials, config) end def sign(:hmac_sha256_iso_passphrase, request, credentials, config) do HmacSha256Iso.sign(request, credentials, config) end def sign(:hmac_sha256_passphrase_signed, request, credentials, config) do HmacSha256Kucoin.sign(request, credentials, config) end def sign(:hmac_sha512_nonce, request, credentials, config) do HmacSha512Nonce.sign(request, credentials, config) end def sign(:hmac_sha512_gate, request, credentials, config) do HmacSha512Gate.sign(request, credentials, config) end def sign(:hmac_sha384_payload, request, credentials, config) do HmacSha384Payload.sign(request, credentials, config) end def sign(:deribit, request, credentials, config) do Deribit.sign(request, credentials, config) end def sign(:custom, request, credentials, config) do Custom.sign(request, credentials, config) end # --- Introspection --- @doc """ Returns the list of supported signing patterns. """ @spec patterns() :: [pattern()] def patterns do [ :hmac_sha256_query, :hmac_sha256_headers, :hmac_sha256_iso_passphrase, :hmac_sha256_passphrase_signed, :hmac_sha512_nonce, :hmac_sha512_gate, :hmac_sha384_payload, :deribit, :custom ] end @doc """ Checks if a pattern is supported. """ @spec pattern?(atom()) :: boolean() def pattern?(pattern), do: pattern in patterns() @doc """ Returns the signing module for a given pattern. """ @spec module_for_pattern(pattern()) :: module() | nil def module_for_pattern(:hmac_sha256_query), do: HmacSha256Query def module_for_pattern(:hmac_sha256_headers), do: HmacSha256Headers def module_for_pattern(:hmac_sha256_iso_passphrase), do: HmacSha256Iso def module_for_pattern(:hmac_sha256_passphrase_signed), do: HmacSha256Kucoin def module_for_pattern(:hmac_sha512_nonce), do: HmacSha512Nonce def module_for_pattern(:hmac_sha512_gate), do: HmacSha512Gate def module_for_pattern(:hmac_sha384_payload), do: HmacSha384Payload def module_for_pattern(:deribit), do: Deribit def module_for_pattern(:custom), do: Custom def module_for_pattern(_), do: nil # --- Shared Crypto Helpers --- # Used by signing pattern modules for building signatures. @doc "Current UTC time in milliseconds." @spec timestamp_ms() :: non_neg_integer() def timestamp_ms, do: System.system_time(:millisecond) @doc "Current UTC time in seconds." @spec timestamp_seconds() :: non_neg_integer() def timestamp_seconds, do: System.system_time(:second) @doc "Current UTC time as ISO 8601 string, truncated to millisecond precision." @spec timestamp_iso8601() :: String.t() def timestamp_iso8601 do DateTime.utc_now() |> DateTime.truncate(:millisecond) |> DateTime.to_iso8601() end # Deterministic-override helpers. # Production callers never set `:timestamp_ms_override` / `:nonce_override` in # config, so behavior is unchanged. Only the fixture-replay test generator # injects these to reproduce CCXT JS signing output byte-for-byte. @doc """ Returns `config[:timestamp_ms_override]` when set, otherwise current wall time in milliseconds. Used by pattern modules so fixture replay (T65) can inject the frozen timestamp CCXT JS signed with. """ @spec timestamp_ms_from_config(map()) :: non_neg_integer() def timestamp_ms_from_config(config) do case Map.get(config, :timestamp_ms_override) do nil -> timestamp_ms() ms when is_integer(ms) -> ms end end @doc "Like `timestamp_ms_from_config/1` but in seconds." @spec timestamp_seconds_from_config(map()) :: non_neg_integer() def timestamp_seconds_from_config(config) do case Map.get(config, :timestamp_ms_override) do nil -> timestamp_seconds() ms when is_integer(ms) -> div(ms, 1000) end end @doc "Like `timestamp_ms_from_config/1` but rendered as ISO 8601 UTC." @spec timestamp_iso8601_from_config(map()) :: String.t() def timestamp_iso8601_from_config(config) do case Map.get(config, :timestamp_ms_override) do nil -> timestamp_iso8601() ms when is_integer(ms) -> ms |> DateTime.from_unix!(:millisecond) |> DateTime.truncate(:millisecond) |> DateTime.to_iso8601() end end @doc """ Returns `config[:nonce_override]` when set, otherwise invokes `fallback`. Fallback is a zero-arity function so callers control nonce semantics (e.g. `:erlang.unique_integer` vs `System.system_time(:microsecond)`). """ @spec nonce_from_config(map(), (-> integer())) :: integer() def nonce_from_config(config, fallback) when is_function(fallback, 0) do case Map.get(config, :nonce_override) do nil -> fallback.() n when is_integer(n) -> n end end @doc "HMAC-SHA256 of `data` with `secret`." @spec hmac_sha256(iodata(), iodata()) :: binary() def hmac_sha256(data, secret) do :crypto.mac(:hmac, :sha256, secret, data) end @doc "HMAC-SHA384 of `data` with `secret`." @spec hmac_sha384(iodata(), iodata()) :: binary() def hmac_sha384(data, secret) do :crypto.mac(:hmac, :sha384, secret, data) end @doc "HMAC-SHA512 of `data` with `secret`." @spec hmac_sha512(iodata(), iodata()) :: binary() def hmac_sha512(data, secret) do :crypto.mac(:hmac, :sha512, secret, data) end @doc "SHA-256 hash of `data`." @spec sha256(iodata()) :: binary() def sha256(data) do :crypto.hash(:sha256, data) end @doc "SHA-512 hash of `data`." @spec sha512(iodata()) :: binary() def sha512(data) do :crypto.hash(:sha512, data) end @doc "Lowercase hex-encodes a binary." @spec encode_hex(binary()) :: String.t() def encode_hex(binary) do Base.encode16(binary, case: :lower) end @doc "Base64-encodes a binary." @spec encode_base64(binary()) :: String.t() def encode_base64(binary) do Base.encode64(binary) end @doc "Decodes a Base64-encoded string. Raises on invalid input." @spec decode_base64(String.t()) :: binary() def decode_base64(encoded) do Base.decode64!(encoded) end @doc "URL-encodes params as a sorted query string." @spec urlencode(map() | nil) :: String.t() def urlencode(nil), do: "" def urlencode(params) when params == %{}, do: "" def urlencode(params) do params |> Enum.sort_by(fn {k, _v} -> to_string(k) end) |> URI.encode_query() end @doc "URL-encodes params as a sorted query string without percent-encoding values." @spec urlencode_raw(map() | nil) :: String.t() def urlencode_raw(nil), do: "" def urlencode_raw(params) when params == %{}, do: "" def urlencode_raw(params) do params |> Enum.sort_by(fn {k, _v} -> to_string(k) end) |> Enum.map_join("&", fn {k, v} -> "#{k}=#{v}" end) end end