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; the table is :protected, so writes
serialize through the owning GenServer (no foreign process can mutate or
overwrite registered credentials).
Not an application child: only test and recording harnesses use it, so callers start it explicitly rather than every consumer paying for it at boot.
When the registry is not running
Because nothing in the library starts it, every function here has to answer for
the case where the caller forgot to. Application.ensure_all_started(:bourse)
does not help — the application starts fine, this process simply is not in its
tree. Ask started?/0 when in doubt. The two halves of the API answer
differently, by shape:
- Writes (
register/3,register_from_env/3,register_all_from_env/1,unregister/2,clear/0) return{:error, :not_started}. They are called from harness setup code, where a matchable value lets the caller report the problem instead of dying inside it. - Reads (
creds/2,creds!/2,registered?/2,registered_exchanges/0,exchanges_with_creds/0) raiseArgumentErrornamingstart_link/1. Their return shapes already mean something (nilis "not registered",falseis "not registered here"), and quietly widening them would let a missing registry read as an empty one.
Usage
# In test_helper.exs - start the registry, then register each sandbox
{:ok, _} = Bourse.Testnet.start_link([])
Bourse.Testnet.register_all_from_env([
{:bybit, testnet: true},
{:binance, testnet: true},
{:binance, :futures, testnet: true}
])
# In tests - retrieve credentials for specific sandbox
creds = Bourse.Testnet.creds(:bybit) # default sandbox
creds = Bourse.Testnet.creds(:binance, :futures) # futures sandboxSandbox Keys
Multi-API exchanges have different testnets per API section:
| Sandbox Key | Env Var Infix | Example Hostname |
|---|---|---|
:default | (none) | testnet.binance.vision |
:futures | _FUTURES | demo-fapi.binance.com |
:coinm | _COINM | demo-dapi.binance.com |
Env Var Convention
{EXCHANGE}[_{SANDBOX}]_TESTNET_API_KEY{EXCHANGE}[_{SANDBOX}]_TESTNET_API_SECRET{EXCHANGE}_PASSPHRASE(ifpassphrase: true)
OKX is an explicit exception: integration tests use the international demo
credentials OKX_INTL_API_KEY, OKX_INTL_API_SECRET, and
OKX_INTL_PASSPHRASE against www.okx.com.
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.
Summary
Types
Config entry for register_all_from_env/1
Returned by every write when the registry process is not running
Options for register_from_env/2,3
Functions
Returns a specification to start this module under a supervisor.
Clears all registered credentials (for test isolation).
Get credentials for an exchange/sandbox. Returns nil if unregistered.
Get credentials or raise ArgumentError with a helpful message.
Env var prefix for a given exchange + sandbox combination.
List unique exchange atoms with any registered credentials.
Register credentials directly.
Register credentials for multiple exchanges from environment variables.
Register credentials from environment variables.
Returns true when credentials are registered for the given exchange/sandbox.
List all registered {exchange, sandbox_key} tuples.
Derive sandbox_key from a sandbox URL's hostname/path.
Starts the Testnet registry GenServer (owns the ETS table).
Returns true when the registry process is running.
Removes the credentials registered for a single {exchange, sandbox_key}.
Types
@type config_entry() :: {atom(), register_opts()} | {atom(), atom(), register_opts()}
Config entry for register_all_from_env/1
@type not_started() :: {:error, :not_started}
Returned by every write when the registry process is not running
@type register_opts() :: [ testnet: boolean(), passphrase: boolean(), sandbox: boolean(), secret_suffix: String.t() ]
Options for register_from_env/2,3
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec clear() :: :ok | not_started()
Clears all registered credentials (for test isolation).
Returns {:error, :not_started} if the registry is not running.
@spec creds(atom(), atom()) :: Bourse.Credentials.t() | nil
Get credentials for an exchange/sandbox. Returns nil if unregistered.
Raises ArgumentError if the registry is not running — nil there would mean
"not registered", which is a claim this function cannot make without a table.
@spec creds!(atom(), atom()) :: Bourse.Credentials.t()
Get credentials or raise ArgumentError with a helpful message.
Env var prefix for a given exchange + sandbox combination.
Examples
iex> Bourse.Testnet.env_var_prefix(:binance, :default)
"BINANCE_TESTNET"
iex> Bourse.Testnet.env_var_prefix(:binance, :futures)
"BINANCE_FUTURES_TESTNET"
@spec exchanges_with_creds() :: [atom()]
List unique exchange atoms with any registered credentials.
Raises ArgumentError if the registry is not running.
@spec register(atom(), atom() | keyword(), keyword()) :: :ok | :skipped | not_started()
Register credentials directly.
Returns :ok if credentials validate, :skipped if required fields are
missing, and {:error, :not_started} if the registry is not running.
@spec register_all_from_env([config_entry()]) :: [{atom(), atom()}] | not_started()
Register credentials for multiple exchanges from environment variables.
Returns the list of successfully registered {exchange, sandbox_key} tuples,
or {:error, :not_started} if the registry is not running — an empty list
means every entry was skipped for absent env vars, which is a different
condition and stays distinguishable.
@spec register_from_env(atom(), atom() | register_opts(), register_opts()) :: :ok | :skipped | not_started()
Register credentials from environment variables.
Env var pattern: {EXCHANGE}[_{SANDBOX}][_TESTNET]_API_KEY|_API_SECRET.
Options
:testnet- Include_TESTNETin env var names (default:false):passphrase- Also load passphrase from{EXCHANGE}_PASSPHRASE(default:false):sandbox- Value forcredentials.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, and
{:error, :not_started} when there are credentials to register but no registry
running to hold them.
Returns true when credentials are registered for the given exchange/sandbox.
Raises ArgumentError if the registry is not running — false there would
claim the credentials are absent when the registry is.
List all registered {exchange, sandbox_key} tuples.
Raises ArgumentError if the registry is not running — an empty list there
would read as "nothing registered yet".
Derive sandbox_key from a sandbox URL's hostname/path.
Examples
iex> Bourse.Testnet.sandbox_key_from_url("https://testnet.binance.vision/api/v3")
:default
iex> Bourse.Testnet.sandbox_key_from_url("https://demo-fapi.binance.com/fapi/v1")
:futures
iex> Bourse.Testnet.sandbox_key_from_url("https://demo-dapi.binance.com/dapi/v1")
:coinm
@spec start_link(keyword()) :: GenServer.on_start()
Starts the Testnet registry GenServer (owns the ETS table).
@spec started?() :: boolean()
Returns true when the registry process is running.
The registry is not an application child, so a consumer that has not started it
will find every read raising and every write answering {:error, :not_started}.
@spec unregister(atom(), atom()) :: :ok | not_started()
Removes the credentials registered for a single {exchange, sandbox_key}.
Returns :ok whether or not an entry existed. Unlike direct :ets.delete/2,
this serializes through the owning GenServer, so it is the correct way to
clean up a single registration from a foreign process (e.g. an ExUnit
on_exit callback, which runs outside the test process). Use this instead of
clear/0 when concurrent (async: true) tests share the registry and a
full wipe would clobber their registrations.
Returns {:error, :not_started} if the registry is not running.