Bourse.Signing (bourse v0.1.0)

Copy Markdown View Source

Signing pattern library for exchange authentication.

Provides a unified interface for signing API requests across the in-scope exchange registry. Authored HMAC recipes cover the common exchange families, with first-party modules for venue-specific signers:

PatternIn-scope useDescription
:hmac_sha256_queryBinance familySign query string
:hmac_sha256_headersBybit-styleSign body/headers
:hmac_sha256_iso_passphraseOKX-styleISO timestamp + passphrase
:deribitDeribitCustom Authorization header format
:hyperliquidHyperliquidEIP-712 / action signing
:deriveDeriveEIP-712 order/message signing
:lighterLighterIsolated official zk-Schnorr signer
:api_key_secret_headersAlpaca-styleAPI key and secret headers

Usage

signed = Bourse.Signing.sign(
  :hmac_sha256_headers,
  %Bourse.Signing.Request{method: :get, path: "/v5/account/wallet-balance"},
  credentials,
  signing_config
)

Pattern selection is authored in each supported runtime spec. Consumers cannot register reference-only exchanges or inject long-tail signers.

Summary

Functions

Decodes a Base64-encoded string. Raises on invalid input.

Base64-encodes a binary.

Lowercase hex-encodes a binary.

Encodes an ordered list of {key, value} pairs as a query string.

HMAC-SHA256 of data with secret.

HMAC-SHA384 of data with secret.

HMAC-SHA512 of data with secret.

Returns the signing module for a given pattern.

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)).

Checks if a pattern is supported.

Returns the list of supported signing patterns.

SHA-256 hash of data.

SHA-512 hash of data.

Signs a request using the specified pattern and configuration.

Current UTC time as ISO 8601 string, truncated to millisecond precision.

Current UTC time in milliseconds.

Returns a preformatted config[:timestamp] first, then config[:timestamp_ms_override], otherwise current wall time in milliseconds. Used by pattern modules so dispatch can provide the v4 sign recipe timestamp and signature-vector tests can inject a frozen provider timestamp.

Current UTC time in seconds.

URL-encodes params as a sorted query string.

URL-encodes params as a sorted query string without percent-encoding values.

Types

config()

@type config() :: %{
  optional(:api_key_header) => String.t(),
  optional(:secret_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 | :url,
  optional(:sign_recipe) => map(),
  optional(:sign_recipe_section) => String.t(),
  optional(atom()) => term()
}

method()

@type method() :: Bourse.Signing.Request.method()

pattern()

@type pattern() ::
  :hmac_sha256_query
  | :hmac_sha256_headers
  | :hmac_sha256_iso_passphrase
  | :deribit
  | :hyperliquid
  | :derive
  | :lighter
  | :api_key_secret_headers

request()

@type request() :: Bourse.Signing.Request.t()

signed_request()

@type signed_request() :: Bourse.Signing.SignedRequest.t()

Functions

decode_base64(encoded)

@spec decode_base64(String.t()) :: binary()

Decodes a Base64-encoded string. Raises on invalid input.

encode_base64(binary)

@spec encode_base64(binary()) :: String.t()

Base64-encodes a binary.

encode_hex(binary)

@spec encode_hex(binary()) :: String.t()

Lowercase hex-encodes a binary.

encode_query_pairs(pairs, opts \\ [])

@spec encode_query_pairs(
  [{term(), term()}],
  keyword()
) :: String.t()

Encodes an ordered list of {key, value} pairs as a query string.

Percent-encodes keys and values the same way as urlencode/1 (space → %20, not +). See that function's moduledoc for the venue-doc rationale.

Options

  • :array_style:brackets (default, key[]=v) or :repeat (key=v repeated)

hmac_sha256(data, secret)

@spec hmac_sha256(iodata(), iodata()) :: binary()

HMAC-SHA256 of data with secret.

hmac_sha384(data, secret)

@spec hmac_sha384(iodata(), iodata()) :: binary()

HMAC-SHA384 of data with secret.

hmac_sha512(data, secret)

@spec hmac_sha512(iodata(), iodata()) :: binary()

HMAC-SHA512 of data with secret.

module_for_pattern(arg1)

@spec module_for_pattern(pattern()) :: module() | nil

Returns the signing module for a given pattern.

nonce_from_config(config, fallback)

@spec nonce_from_config(map(), (-> integer())) :: integer()

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)).

pattern?(pattern)

@spec pattern?(atom()) :: boolean()

Checks if a pattern is supported.

patterns()

@spec patterns() :: [pattern()]

Returns the list of supported signing patterns.

sha256(data)

@spec sha256(iodata()) :: binary()

SHA-256 hash of data.

sha512(data)

@spec sha512(iodata()) :: binary()

SHA-512 hash of data.

sign(pattern, request, credentials, config)

@spec sign(pattern(), request(), Bourse.Credentials.t(), config()) ::
  signed_request()
  | {:error, {:unsupported_signing, term()} | {:lighter_signing, term()}}

Signs a request using the specified pattern and configuration.

Parameters

  • pattern - The signing pattern atom (e.g., :hmac_sha256_headers)
  • request - Bourse.Signing.Request or equivalent map (:method, :path, :body, :params)
  • credentials - Bourse.Credentials struct with API key and secret
  • config - Pattern-specific configuration from the exchange spec

Returns

A Bourse.Signing.SignedRequest with :url, :method, :headers, and :body.

timestamp_iso8601()

@spec timestamp_iso8601() :: String.t()

Current UTC time as ISO 8601 string, truncated to millisecond precision.

timestamp_iso8601_from_config(config)

@spec timestamp_iso8601_from_config(map()) :: String.t()

Like timestamp_ms_from_config/1 but rendered as ISO 8601 UTC.

timestamp_ms()

@spec timestamp_ms() :: non_neg_integer()

Current UTC time in milliseconds.

timestamp_ms_from_config(config)

@spec timestamp_ms_from_config(map()) :: non_neg_integer() | String.t()

Returns a preformatted config[:timestamp] first, then config[:timestamp_ms_override], otherwise current wall time in milliseconds. Used by pattern modules so dispatch can provide the v4 sign recipe timestamp and signature-vector tests can inject a frozen provider timestamp.

timestamp_seconds()

@spec timestamp_seconds() :: non_neg_integer()

Current UTC time in seconds.

timestamp_seconds_from_config(config)

@spec timestamp_seconds_from_config(map()) :: non_neg_integer() | String.t()

Like timestamp_ms_from_config/1 but in seconds.

urlencode(params)

@spec urlencode(map() | keyword() | [{term(), term()}] | nil) :: String.t()

URL-encodes params as a sorted query string.

Uses RFC 3986 percent-encoding via URI.encode/2 with URI.char_unreserved?/1spaces become %20, never the application/x-www-form-urlencoded + that Elixir's URI.encode_query/1 emits. Venue authority for the default: Huobi/HTX spot signing docs require URL-encoded query params with uppercase hex and show the space as %20 (not +) — https://huobiapi.github.io/docs/spot/v1/en/#authentication (Signature Method). Hex digits from URI.encode/2 are uppercase, matching the same Huobi rule. No first-class venue has been observed to require www-form + for the signed canonical query — if one does, register a per-venue carve rather than reverting this default (see docs/authored-specs.md C21).

List-valued scalar params use empty-bracket keys (%{"ids" => ["a", "b"]}"ids%5B%5D=a&ids%5B%5D=b"). That is the form Deribit's JSON-RPC-over-GET accepts live (OpenAPI lists style=form, explode=true, but the live parser only materializes a list for key[]=…; Bracket-index ids[0]=… is rejected with value required, and bare repeated keys with value must be a list. Nested list/map items raise ArgumentError naming the param — never URI.encode_query's opaque list crash.

Recipe venues that need plain repeated keys use urlencodeWithArrayRepeat via encode_query_pairs/2.

urlencode_raw(params)

@spec urlencode_raw(map() | nil) :: String.t()

URL-encodes params as a sorted query string without percent-encoding values.