Current section
Files
Jump to
Current section
Files
lib/polymarket_clob/order/builder.ex
defmodule PolymarketClob.Order.Builder do
@moduledoc """
Local order amount calculation and signed order construction.
This module mirrors the current official Python order builder for CLOB exchange orders,
but it does not post orders to the API.
"""
alias PolymarketClob.{Client, Config}
alias PolymarketClob.Order.{Rounding, Signing}
@type side :: :buy | :sell | String.t() | 0 | 1
@type amount_result :: %{side: 0 | 1, maker_amount: String.t(), taker_amount: String.t()}
@doc """
Calculates maker/taker amounts for a limit order.
"""
@spec limit_amounts(side(), number(), number(), String.t()) :: amount_result()
def limit_amounts(side, size, price, tick_size) do
config = Rounding.config!(tick_size)
raw_price = Rounding.round_normal(price, config.price)
case normalize_side(side) do
0 ->
raw_taker_amount = Rounding.round_down(size, config.size)
raw_maker_amount = adjust_precision(raw_taker_amount * raw_price, config.amount)
amount_result(0, raw_maker_amount, raw_taker_amount)
1 ->
raw_maker_amount = Rounding.round_down(size, config.size)
raw_taker_amount = adjust_precision(raw_maker_amount * raw_price, config.amount)
amount_result(1, raw_maker_amount, raw_taker_amount)
end
end
@doc """
Calculates maker/taker amounts for a market order.
The current Python client uses `round_down` for the market order price.
"""
@spec market_amounts(side(), number(), number(), String.t()) :: amount_result()
def market_amounts(side, amount, price, tick_size) do
config = Rounding.config!(tick_size)
raw_price = Rounding.round_down(price, config.price)
case normalize_side(side) do
0 ->
# A BUY market order divides the quote amount by the price. Guard a
# non-positive or price-rounded-to-zero here with a clear error instead
# of crashing with an opaque ArithmeticError (or producing Infinity).
validate_positive_price!(raw_price, price)
raw_maker_amount = Rounding.round_down(amount, config.size)
raw_taker_amount = adjust_precision(raw_maker_amount / raw_price, config.amount)
amount_result(0, raw_maker_amount, raw_taker_amount)
1 ->
raw_maker_amount = Rounding.round_down(amount, config.size)
raw_taker_amount = adjust_precision(raw_maker_amount * raw_price, config.amount)
amount_result(1, raw_maker_amount, raw_taker_amount)
end
end
@doc """
Builds and signs a limit order without posting it.
"""
@spec build_limit_order(Client.t(), String.t(), number(), number(), side(), keyword()) :: map()
def build_limit_order(%Client{} = client, token_id, size, price, side, opts) do
amounts = limit_amounts(side, size, price, Keyword.fetch!(opts, :tick_size))
build_signed_order(client, token_id, amounts, opts)
end
@doc """
Builds and signs a market order without posting it.
"""
@spec build_market_order(Client.t(), String.t(), number(), number(), side(), keyword()) :: map()
def build_market_order(%Client{} = client, token_id, amount, price, side, opts) do
amounts = market_amounts(side, amount, price, Keyword.fetch!(opts, :tick_size))
build_signed_order(client, token_id, amounts, opts)
end
@doc """
Builds an unsigned order map from already calculated maker/taker amounts.
"""
@spec unsigned_order(Client.t(), String.t(), amount_result(), keyword()) :: map()
def unsigned_order(%Client{} = client, token_id, amounts, opts) do
%{
"salt" => to_string(Keyword.get(opts, :salt, generate_salt())),
"maker" => Keyword.get(opts, :maker, Client.funder_address(client)),
"signer" => client.address,
"tokenId" => token_id,
"makerAmount" => amounts.maker_amount,
"takerAmount" => amounts.taker_amount,
"side" => amounts.side,
"signatureType" => Keyword.get(opts, :signature_type, client.signature_type),
"timestamp" => to_string(Keyword.get(opts, :timestamp, System.system_time(:millisecond))),
"metadata" => Keyword.get(opts, :metadata, Config.bytes32_zero()),
"builder" => Keyword.get(opts, :builder, Config.bytes32_zero()),
"expiration" => to_string(Keyword.get(opts, :expiration, 0))
}
end
defp build_signed_order(client, token_id, amounts, opts) do
unsigned = unsigned_order(client, token_id, amounts, opts)
neg_risk = Keyword.get(opts, :neg_risk, false)
exchange = Config.exchange_address(client.chain_id, neg_risk: neg_risk)
signature = Signing.sign(unsigned, client.private_key, client.chain_id, exchange)
Map.put(unsigned, "signature", signature)
end
defp validate_positive_price!(raw_price, _price) when raw_price > 0, do: :ok
defp validate_positive_price!(_raw_price, price) do
raise ArgumentError,
"market BUY price must be positive after rounding, got: #{inspect(price)}"
end
defp amount_result(side, maker_amount, taker_amount) do
%{
side: side,
maker_amount: to_string(Rounding.to_token_decimals(maker_amount)),
taker_amount: to_string(Rounding.to_token_decimals(taker_amount))
}
end
defp adjust_precision(value, max_decimals) do
if Rounding.decimal_places(value) > max_decimals do
adjusted = Rounding.round_up(value, max_decimals + 4)
if Rounding.decimal_places(adjusted) > max_decimals do
Rounding.round_down(adjusted, max_decimals)
else
adjusted
end
else
value
end
end
defp normalize_side(:buy), do: 0
defp normalize_side("BUY"), do: 0
defp normalize_side(0), do: 0
defp normalize_side(:sell), do: 1
defp normalize_side("SELL"), do: 1
defp normalize_side(1), do: 1
defp normalize_side(side) do
raise ArgumentError, "side must be BUY or SELL, got: #{inspect(side)}"
end
# Cryptographically-strong random salt. Erlang's seedable PRNG is unsafe
# here: its per-process state is seeded from the PID + boot time and can
# repeat the same sequence across BEAM restarts within the same wall-clock
# second, producing salt collisions → identical order hashes → CLOB rejects
# the duplicate or, worse, treats it as the original. 64 bits of strong
# entropy makes accidental collisions astronomically unlikely.
#
# The source-scan test in `test/polymarket_clob/order/builder_salt_test.exs`
# enforces that this module never reaches into the seedable PRNG module by
# name. See also durable invariant R13 #49 from the live bot's audit history.
defp generate_salt do
# 48 bits: within the JSON/JS safe-integer range (< 2^53) so the CLOB JSON
# parser cannot lose precision or overflow int64. Full 64-bit salts (values
# > 2^63) broke the exchange signature check ("Invalid order payload") ~half
# the time. 48 bits stays collision-astronomically-safe at our order volume.
<<n::unsigned-integer-size(48)>> = :crypto.strong_rand_bytes(6)
n
end
end