DpExchange.Webull.Auth (DpExchangeWebull v0.1.1)

Copy Markdown View Source

Signs a request with credentials the host has already obtained. Internal.

This package does not handle authentication

It signs. The host implements authentication, holds the credentials, and decides which kind to use. This module is handed the result and turns it into headers — it never obtains a credential, never stores one, never refreshes one, and never reads one from the environment.

Webull's scheme

Every authenticated request carries:

x-app-key:              <app key>
x-timestamp:            <ISO 8601 UTC, second precision, "…Z">
x-signature:            base64(HMAC-SHA1(app_secret <> "&", encoded_str))
x-signature-algorithm:  HMAC-SHA1
x-signature-version:    1.0
x-signature-nonce:      <unique random string>
x-version:              v2
host:                   <api hostname, no scheme>
x-access-token:         <token>   # only when the account has 2FA enabled

The signature, step by step

  1. Collect the signing inputs: every query parameter, plus exactly six header pairs — host, x-app-key, x-signature-algorithm, x-signature-nonce, x-signature-version, x-timestamp. x-signature and x-version deliberately do not participate.
  2. Sort by name ascending and join as name=value&name=value&…str1.
  3. If there is a body, uppercase-hex MD5(body)str2. With no body, omit it entirely — not an MD5 of the empty string, which is a different value and a different signature.
  4. str3 = path & str1, or path & str1 & str2 when a body is present.
  5. Percent-encode str3 with no safe characters, so / becomes %2F and : becomes %3A.
  6. The HMAC key is app_secret <> "&" — the trailing ampersand is part of the key, not a separator. Sign with SHA-1 and base64 the result.

The host participates in the signature, which is what stops a captured signature being replayed against a different environment. It is also why moving between production and the UAT host changes the signature rather than just the URL.

Verified against the venue's own worked example

Webull publishes an example whose expected output is kvlS6opdZDhEBo5jq40nHYXaLvM=. The test suite runs that example end to end and asserts the match, which is the only way to know the six steps above were read the same way the venue meant them.

Summary

Types

Credentials the host obtained. Signed with, and not kept.

Everything the signature covers.

Functions

The full header set for a signed request.

A fresh per-request nonce — 32 random hex characters.

The current time in the venue's required form: YYYY-MM-DDThh:mm:ssZ.

Types

credentials()

@type credentials() :: %{
  :app_key => String.t(),
  :app_secret => String.t(),
  optional(:access_token) => String.t() | nil
}

Credentials the host obtained. Signed with, and not kept.

request()

@type request() :: %{
  path: String.t(),
  query_params: %{optional(String.t()) => String.t()},
  body: String.t(),
  host: String.t(),
  timestamp: String.t(),
  nonce: String.t()
}

Everything the signature covers.

Functions

headers(request, credentials)

@spec headers(request(), credentials()) ::
  {:ok, [{String.t(), String.t()}]} | {:error, term()}

The full header set for a signed request.

host is the API hostname without a scheme. It participates in signing, so a caller must pass the host it will actually reach.

Returns {:error, {:missing_credentials, :webull}} rather than signing with a partial credential — a request signed with an absent secret fails at the venue with an error about signatures, which sends the reader looking in the wrong place.

nonce()

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

A fresh per-request nonce — 32 random hex characters.

signature(app_key, app_secret, path, query_params, body, host, timestamp, nonce)

@spec signature(
  String.t(),
  String.t(),
  String.t(),
  %{optional(String.t()) => String.t()},
  String.t(),
  String.t(),
  String.t(),
  String.t()
) :: String.t()

The raw signature value.

Exposed so the venue's published worked example can be verified directly — a signature scheme with six ordering-sensitive steps is not something to assume was read correctly.

timestamp(now \\ DateTime.utc_now())

@spec timestamp(DateTime.t()) :: String.t()

The current time in the venue's required form: YYYY-MM-DDThh:mm:ssZ.

Second precision with no fractional part. A fractional second changes the string, and the string is signed.