Packages

Elixir client library for cryptocurrency exchanges — generated from CCXT specs via compile-time macros.

Current section

Files

Jump to
ccxt_client lib ccxt testnet.ex
Raw

lib/ccxt/testnet.ex

defmodule CCXT.Testnet do
@moduledoc """
ETS-backed credential registry for integration testing.
Supports multiple credential sets per exchange for multi-API exchanges
(e.g., Binance spot vs futures testnets require separate credentials).
Credentials are registered once at test startup (in `test_helper.exs`),
then retrieved by generated tests at runtime. ETS with `read_concurrency: true`
allows lock-free reads from any process.
## Usage
# In test_helper.exs - register credentials for each sandbox
CCXT.Testnet.register_all_from_env([
{:bybit, testnet: true},
{:binance, testnet: true},
{:binance, :futures, testnet: true},
{:okx, testnet: true, passphrase: true}
])
# In tests - retrieve credentials for specific sandbox
creds = CCXT.Testnet.creds(:bybit) # default sandbox
creds = CCXT.Testnet.creds(:binance, :futures) # futures sandbox
## Sandbox Keys
Multi-API exchanges have different testnets per API section:
| Sandbox Key | Env Var Infix | Example Hostname |
|-------------|---------------|----------------------------|
| `:default` | (none) | testnet.binance.vision |
| `:futures` | `_FUTURES` | testnet.binancefuture.com |
| `:coinm` | `_COINM` | testnet.binancefuture.com/dapi |
## Env Var Convention
- `{EXCHANGE}[_{SANDBOX}]_TESTNET_API_KEY`
- `{EXCHANGE}[_{SANDBOX}]_TESTNET_API_SECRET`
- `{EXCHANGE}_PASSPHRASE` (if `passphrase: true`)
When `:testnet` is true, the `_TEST_` infix is accepted as a silent fallback
(e.g. `BINANCE_FUTURES_TEST_API_KEY` is treated as an alias for
`BINANCE_FUTURES_TESTNET_API_KEY`). The canonical `_TESTNET_` name remains
preferred and is the one surfaced in `creds!/2` error messages.
"""
use GenServer
@table :ccxt_testnet_registry
@typedoc "Options for register_from_env/2,3"
@type register_opts :: [
testnet: boolean(),
passphrase: boolean(),
sandbox: boolean(),
secret_suffix: String.t()
]
@typedoc "Config entry for register_all_from_env/1"
@type config_entry ::
{atom(), register_opts()}
| {atom(), atom(), register_opts()}
# =============================================================================
# Client API
# =============================================================================
@doc "Starts the Testnet registry GenServer (owns the ETS table)."
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Register credentials directly.
Returns `:ok` if credentials validate, `:skipped` if required fields are missing.
"""
@spec register(atom(), atom() | keyword(), keyword()) :: :ok | :skipped
def register(exchange, sandbox_key_or_opts, opts \\ [])
def register(exchange, sandbox_key, opts) when is_atom(exchange) and is_atom(sandbox_key) and is_list(opts) do
case CCXT.Credentials.new(opts) do
{:ok, credentials} ->
:ets.insert(@table, {{exchange, sandbox_key}, credentials})
:ok
# Absent required fields → :skipped (env vars not set is a normal case)
{:error, :missing_api_key} ->
:skipped
{:error, :missing_secret} ->
:skipped
# Malformed input (wrong type, unknown key) is a setup bug — fail loudly
{:error, reason} ->
raise ArgumentError,
"CCXT.Testnet.register/3 got invalid credentials for " <>
"#{inspect({exchange, sandbox_key})}: #{inspect(reason)}"
end
end
def register(exchange, opts, []) when is_atom(exchange) and is_list(opts) do
register(exchange, :default, opts)
end
@doc """
Register credentials from environment variables.
Env var pattern: `{EXCHANGE}[_{SANDBOX}][_TESTNET]_API_KEY|_API_SECRET`.
## Options
- `:testnet` - Include `_TESTNET` in env var names (default: `false`)
- `:passphrase` - Also load passphrase from `{EXCHANGE}_PASSPHRASE` (default: `false`)
- `:sandbox` - Value for `credentials.sandbox` (default: value of `:testnet`)
- `:secret_suffix` - Override secret env var suffix (default: `"API_SECRET"`)
Returns `:ok` on success, `:skipped` when required env vars are absent.
"""
@spec register_from_env(atom(), atom() | register_opts(), register_opts()) ::
:ok | :skipped
def register_from_env(exchange, sandbox_key_or_opts \\ :default, opts \\ [])
def register_from_env(exchange, sandbox_key, opts) when is_atom(exchange) and is_atom(sandbox_key) and is_list(opts) do
case read_env_credentials(exchange, sandbox_key, opts) do
:skipped -> :skipped
creds -> register(exchange, sandbox_key, creds)
end
end
def register_from_env(exchange, opts, []) when is_atom(exchange) and is_list(opts) do
register_from_env(exchange, :default, opts)
end
defp read_env_credentials(exchange, sandbox_key, opts) do
prefix = exchange |> Atom.to_string() |> String.upcase()
sandbox_infix = sandbox_key_to_infix(sandbox_key)
testnet = Keyword.get(opts, :testnet, false)
testnet_parts = if testnet, do: ["_TESTNET", "_TEST"], else: [""]
secret_suffix = Keyword.get(opts, :secret_suffix, "API_SECRET")
passphrase_opt = Keyword.get(opts, :passphrase, false)
{key_var, api_key} = resolve_env(prefix, sandbox_infix, testnet_parts, "API_KEY")
{secret_var, secret} = resolve_env(prefix, sandbox_infix, testnet_parts, secret_suffix)
password = if passphrase_opt, do: System.get_env("#{prefix}_PASSPHRASE")
build_env_credentials(
%{api_key: api_key, secret: secret, password: password},
%{
prefix: prefix,
key_var: key_var,
secret_var: secret_var,
passphrase_opt: passphrase_opt,
sandbox: Keyword.get(opts, :sandbox, testnet)
}
)
end
# Resolve an env var by trying each testnet_part in order. Returns
# `{var_name, value}` where `var_name` is the canonical (first) candidate
# — used for error/:skipped messaging — and `value` is the first non-nil
# `System.get_env/1` hit across candidates, or `nil` when none are set.
defp resolve_env(prefix, sandbox_infix, testnet_parts, suffix) do
candidates =
Enum.map(testnet_parts, fn part ->
"#{prefix}#{sandbox_infix}#{part}_#{suffix}"
end)
case Enum.find_value(candidates, &fetch_env/1) do
nil -> {hd(candidates), nil}
{_var, value} -> {hd(candidates), value}
end
end
defp fetch_env(var) do
case System.get_env(var) do
nil -> nil
value -> {var, value}
end
end
defp build_env_credentials(%{api_key: nil}, _ctx), do: :skipped
defp build_env_credentials(%{secret: nil}, _ctx), do: :skipped
defp build_env_credentials(%{password: nil}, %{passphrase_opt: true} = ctx) do
raise ArgumentError,
"CCXT.Testnet: #{ctx.key_var} and #{ctx.secret_var} are set but " <>
"#{ctx.prefix}_PASSPHRASE is missing. " <>
"Either set #{ctx.prefix}_PASSPHRASE or omit passphrase: true."
end
defp build_env_credentials(%{api_key: k, secret: s, password: p}, %{sandbox: sandbox}) do
[api_key: k, secret: s, password: p, sandbox: sandbox]
end
@doc """
Register credentials for multiple exchanges from environment variables.
Returns the list of successfully registered `{exchange, sandbox_key}` tuples.
"""
@spec register_all_from_env([config_entry()]) :: [{atom(), atom()}]
def register_all_from_env(configs) when is_list(configs) do
for config <- configs,
result = register_config(config),
result != :skipped do
result
end
end
defp register_config({exchange, sandbox_key, opts}) when is_atom(exchange) and is_atom(sandbox_key) and is_list(opts) do
case register_from_env(exchange, sandbox_key, opts) do
:ok -> {exchange, sandbox_key}
:skipped -> :skipped
end
end
defp register_config({exchange, opts}) when is_atom(exchange) and is_list(opts) do
case register_from_env(exchange, :default, opts) do
:ok -> {exchange, :default}
:skipped -> :skipped
end
end
@doc """
Get credentials for an exchange/sandbox. Returns `nil` if unregistered.
"""
@spec creds(atom(), atom()) :: CCXT.Credentials.t() | nil
def creds(exchange, sandbox_key \\ :default) when is_atom(exchange) and is_atom(sandbox_key) do
case :ets.lookup(@table, {exchange, sandbox_key}) do
[{_key, credentials}] -> credentials
[] -> nil
end
end
@doc """
Get credentials or raise `ArgumentError` with a helpful message.
"""
@spec creds!(atom(), atom()) :: CCXT.Credentials.t()
def creds!(exchange, sandbox_key \\ :default) when is_atom(exchange) and is_atom(sandbox_key) do
case creds(exchange, sandbox_key) do
nil ->
key_str =
if sandbox_key == :default, do: "#{exchange}", else: "#{exchange}/#{sandbox_key}"
raise ArgumentError,
"No credentials registered for #{key_str}. " <>
"Set #{env_var_prefix(exchange, sandbox_key)}_API_KEY and " <>
"#{env_var_prefix(exchange, sandbox_key)}_API_SECRET."
credentials ->
credentials
end
end
@doc "Returns `true` when credentials are registered for the given exchange/sandbox."
@spec registered?(atom(), atom()) :: boolean()
def registered?(exchange, sandbox_key \\ :default) when is_atom(exchange) and is_atom(sandbox_key) do
:ets.member(@table, {exchange, sandbox_key})
end
@doc "Clears all registered credentials (for test isolation)."
@spec clear() :: :ok
def clear do
:ets.delete_all_objects(@table)
:ok
end
@doc "List all registered `{exchange, sandbox_key}` tuples."
@spec registered_exchanges() :: [{atom(), atom()}]
def registered_exchanges do
:ets.select(@table, [{{:"$1", :_}, [], [:"$1"]}])
end
@doc "List unique exchange atoms with any registered credentials."
@spec exchanges_with_creds() :: [atom()]
def exchanges_with_creds do
registered_exchanges()
|> Enum.map(fn {exchange, _sandbox_key} -> exchange end)
|> Enum.uniq()
end
# =============================================================================
# Sandbox Key Detection
# =============================================================================
@doc """
Derive sandbox_key from a sandbox URL's hostname/path.
## Examples
iex> CCXT.Testnet.sandbox_key_from_url("https://testnet.binance.vision/api/v3")
:default
iex> CCXT.Testnet.sandbox_key_from_url("https://testnet.binancefuture.com/fapi/v1")
:futures
iex> CCXT.Testnet.sandbox_key_from_url("https://testnet.binancefuture.com/dapi/v1")
:coinm
"""
@spec sandbox_key_from_url(String.t() | nil) :: atom()
def sandbox_key_from_url(nil), do: :default
def sandbox_key_from_url(url) when is_binary(url) do
uri = URI.parse(url)
host = uri.host || ""
path = uri.path || ""
cond do
String.contains?(path, "/dapi") -> :coinm
String.contains?(host, "future") -> :futures
true -> :default
end
end
@doc """
Env var prefix for a given exchange + sandbox combination.
## Examples
iex> CCXT.Testnet.env_var_prefix(:binance, :default)
"BINANCE_TESTNET"
iex> CCXT.Testnet.env_var_prefix(:binance, :futures)
"BINANCE_FUTURES_TESTNET"
"""
@spec env_var_prefix(atom(), atom()) :: String.t()
def env_var_prefix(exchange, sandbox_key) do
prefix = exchange |> Atom.to_string() |> String.upcase()
infix = sandbox_key_to_infix(sandbox_key)
"#{prefix}#{infix}_TESTNET"
end
defp sandbox_key_to_infix(:default), do: ""
defp sandbox_key_to_infix(:futures), do: "_FUTURES"
defp sandbox_key_to_infix(:coinm), do: "_COINM"
defp sandbox_key_to_infix(key) when is_atom(key), do: "_" <> (key |> Atom.to_string() |> String.upcase())
# =============================================================================
# GenServer Callbacks
# =============================================================================
@impl true
def init(_opts) do
table = :ets.new(@table, [:named_table, :set, :public, read_concurrency: true])
{:ok, %{table: table}}
end
end