DpExchange.Webull.Auth (DpExchangeWebull v0.4.28)

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
Content-Type:           application/json   # only when there is a body to declare

Content-Type was missing entirely until DpCryptoManagement's issue #18: every signed request with a body — subscribe/3, unsubscribe/3, place_order and every other JSON-bodied POST this package signs — went out with no declared media type at all, and the venue's streaming-subscribe endpoint answered every one of them HTTP 415 "Request media type not support". Streaming never delivered a tick as a result. It is not one of the six signed header pairs below, so adding it cannot desync a request from what was actually signed.

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.

Whether credentials has the shape headers/2 accepts — checked without building a request, signing anything, or dialling out.

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.

present?(arg1)

@spec present?(credentials() | term()) ::
  :ok | {:error, {:missing_credentials, :webull}}

Whether credentials has the shape headers/2 accepts — checked without building a request, signing anything, or dialling out.

headers/2 is the gate every signed call in this package passes through, so nothing else normally needs its own copy of this check. Rest.get_fees/2 is the one exception: it answers a flat, published rate and builds no request at all, so headers/2 never runs for it — but this venue's own moduledoc states there is no anonymous path on it anywhere, and an endpoint that skipped the check purely because it has no HTTP call to hang it on would be the one silent exception to that claim. DpExchange.Webull.Fake calls this too, for the same reason its own credentialed callbacks need it: so the fake's gate cannot drift from this one.

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.