Packages
polymarket_sdk
0.2.0
Higher-level Elixir SDK for Polymarket: Gamma market discovery, Data API, CTF/pUSD helpers, and convenience facades. Depends on polymarket_clob for the low-level CLOB surface.
Current section
Files
Jump to
Current section
Files
lib/polymarket/rpc.ex
defmodule Polymarket.RPC do
@moduledoc """
Stateless JSON-RPC 2.0 client for Polygon (and any other EVM chain
that speaks standard `eth_*` JSON-RPC).
The building blocks a transaction-submission layer needs:
* `call/3` — raw JSON-RPC call returning the parsed `result`.
* `chain_id/1`, `block_number/1`, `gas_price/1` — uint helpers.
* `nonce/2`/`nonce/3` — `eth_getTransactionCount`, default block
tag `"pending"`.
* `estimate_gas/2` — `eth_estimateGas` with Elixir-friendly param
shape (atom keys, integer amounts).
* `send_raw_transaction/2` — accepts a hex string, raw binary, or
a signed `Polymarket.Tx` struct.
This module is intentionally stateless. The `Polymarket.RPC` struct
carries `:url`, `:headers`, `:timeout`, `:retry`, `:max_retries`,
and `:plug` (test-only). No GenServer, no connection pool of our
own — Req's Finch pool handles transport. Caller threads the struct
through calls.
## Response contract
* `{:ok, result}` — successful RPC response. `result` is the
decoded `result` field (a hex string for most `eth_*` calls, a
map or list for others). Typed helpers parse `result` into a
typed value before returning.
* `{:error, {:rpc_error, code, message, data}}` — JSON-RPC
protocol error returned by the node. `data` is `nil` when the
node didn't include it.
* `{:error, {:http_error, status, body}}` — non-2xx HTTP response
from the node.
* `{:error, {:transport_error, reason}}` — Req-level transport
failure.
* `{:error, {:invalid_response, body_or_message}}` — well-formed
HTTP 200 but the body is missing the expected `result` /
`error` shape, or a typed helper got back something it couldn't
parse (e.g. `chain_id/1` got non-hex).
## Scope
This module ships **only** the JSON-RPC client and nonce/gas
primitives. Higher-level submit wrappers
(`Polymarket.CTF.redeem_positions/2` etc.) compose calldata
(`Polymarket.ABI`) + tx serialization (`Polymarket.Tx`) + this RPC
layer.
## Options
* `:url` *(required)* — RPC endpoint URL (e.g. `"https://polygon-rpc.com"`).
* `:headers` — extra request headers (default `[]`).
* `:timeout` — `receive_timeout` in milliseconds (default `15_000`).
* `:retry` — Req retry policy. Default `false`. JSON-RPC POSTs are
not idempotent at the application layer (sending a tx twice
double-spends nonce); leave retry off unless the caller knows
the specific RPC method is safe to retry.
* `:max_retries` — Req max retries (default `0`).
* `:plug` — test-only; injects a Req.Test plug into the request.
"""
alias Polymarket.Tx
@type t :: %__MODULE__{
url: String.t(),
headers: list(),
timeout: pos_integer(),
retry: term(),
max_retries: non_neg_integer(),
plug: term()
}
defstruct url: nil,
headers: [],
timeout: 15_000,
retry: false,
max_retries: 0,
plug: nil
@default_timeout 15_000
@doc """
Builds a `Polymarket.RPC` config struct.
Required: `:url`. Optional: `:headers`, `:timeout`, `:retry`,
`:max_retries`, `:plug`.
"""
@spec new(keyword()) :: t()
def new(opts) do
url = Keyword.fetch!(opts, :url)
unless is_binary(url) and url != "" do
raise ArgumentError, ":url must be a non-empty string"
end
%__MODULE__{
url: url,
headers: Keyword.get(opts, :headers, []),
timeout: Keyword.get(opts, :timeout, @default_timeout),
retry: Keyword.get(opts, :retry, false),
max_retries: Keyword.get(opts, :max_retries, 0),
plug: Keyword.get(opts, :plug)
}
end
@doc """
Performs a raw JSON-RPC call. Returns the decoded `result` field on
success; see the moduledoc for the full error shape.
"""
@spec call(t(), String.t(), list()) :: {:ok, term()} | {:error, term()}
def call(%__MODULE__{} = client, method, params)
when is_binary(method) and is_list(params) do
body = %{
"jsonrpc" => "2.0",
"id" => 1,
"method" => method,
"params" => params
}
req_opts =
[
method: :post,
url: client.url,
json: body,
headers: client.headers,
receive_timeout: client.timeout,
retry: client.retry,
max_retries: client.max_retries
]
|> maybe_put(:plug, client.plug)
req_opts
|> Req.request()
|> normalize_rpc()
end
# ── Typed helpers ──
@doc "Calls `eth_chainId`. Returns the chain id as a non-negative integer."
@spec chain_id(t()) :: {:ok, non_neg_integer()} | {:error, term()}
def chain_id(%__MODULE__{} = client) do
client |> call("eth_chainId", []) |> parse_uint_hex()
end
@doc "Calls `eth_blockNumber`. Returns the latest block number as an integer."
@spec block_number(t()) :: {:ok, non_neg_integer()} | {:error, term()}
def block_number(%__MODULE__{} = client) do
client |> call("eth_blockNumber", []) |> parse_uint_hex()
end
@doc "Calls `eth_gasPrice`. Returns wei as an integer."
@spec gas_price(t()) :: {:ok, non_neg_integer()} | {:error, term()}
def gas_price(%__MODULE__{} = client) do
client |> call("eth_gasPrice", []) |> parse_uint_hex()
end
@doc """
Calls `eth_getTransactionCount(address, block_tag)`. Default block
tag is `"pending"` so the returned nonce is suitable for the next
transaction the caller will sign.
"""
@spec nonce(t(), String.t(), String.t()) :: {:ok, non_neg_integer()} | {:error, term()}
def nonce(%__MODULE__{} = client, address, block_tag \\ "pending")
when is_binary(block_tag) do
validated = validate_address!(:address, address)
client
|> call("eth_getTransactionCount", [validated, block_tag])
|> parse_uint_hex()
end
@doc """
Calls `eth_estimateGas`. `params` is a map with optional keys:
* `:from` — sender address (`0x`-prefixed hex).
* `:to` — destination address (`0x`-prefixed hex).
* `:value` — non-negative integer (wei).
* `:gas` — non-negative integer (gas units).
* `:gas_price` — non-negative integer (wei).
* `:data` — binary or `0x`-prefixed hex string.
Integer amounts are encoded as `0x`-prefixed hex on the wire.
"""
@spec estimate_gas(t(), map()) :: {:ok, non_neg_integer()} | {:error, term()}
def estimate_gas(%__MODULE__{} = client, params) when is_map(params) do
rpc_params = format_call_params(params)
client |> call("eth_estimateGas", [rpc_params]) |> parse_uint_hex()
end
@doc """
Calls `eth_sendRawTransaction(signed_bytes)`. Returns the
`0x`-prefixed transaction hash from the node on success.
Accepts:
* a `Polymarket.Tx` struct that has been signed (`:v` populated);
* a `0x`-prefixed hex string;
* a raw binary (will be hex-encoded with `0x` prefix).
"""
@spec send_raw_transaction(t(), Tx.t() | binary()) :: {:ok, String.t()} | {:error, term()}
def send_raw_transaction(%__MODULE__{} = client, %Tx{v: v} = tx) when is_integer(v) do
send_raw_transaction(client, Tx.serialize(tx))
end
def send_raw_transaction(%__MODULE__{} = client, "0x" <> _ = signed_hex) do
case call(client, "eth_sendRawTransaction", [signed_hex]) do
{:ok, "0x" <> _ = tx_hash} -> {:ok, tx_hash}
{:ok, other} -> {:error, {:invalid_response, other}}
{:error, _} = err -> err
end
end
def send_raw_transaction(%__MODULE__{} = client, signed) when is_binary(signed) do
send_raw_transaction(client, "0x" <> Base.encode16(signed, case: :lower))
end
# ── Private ──
defp normalize_rpc({:ok, %{status: 200, body: %{"result" => result}}}) do
{:ok, result}
end
defp normalize_rpc(
{:ok, %{status: 200, body: %{"error" => %{"code" => code, "message" => msg} = err}}}
)
when is_integer(code) and is_binary(msg) do
{:error, {:rpc_error, code, msg, Map.get(err, "data")}}
end
defp normalize_rpc({:ok, %{status: 200, body: body}}) do
{:error, {:invalid_response, body}}
end
defp normalize_rpc({:ok, %{status: status, body: body}}) do
{:error, {:http_error, status, body}}
end
defp normalize_rpc({:error, reason}) do
{:error, {:transport_error, reason}}
end
defp parse_uint_hex({:ok, "0x" <> hex}) do
case Integer.parse(hex, 16) do
{n, ""} -> {:ok, n}
_ -> {:error, {:invalid_response, "expected hex integer, got: 0x#{hex}"}}
end
end
defp parse_uint_hex({:ok, other}) do
{:error, {:invalid_response, other}}
end
defp parse_uint_hex({:error, _} = err), do: err
defp format_call_params(params) do
# Each helper threads (wire_key, user_key, value). The wire_key is
# the JSON-RPC field name on the request body; the user_key is the
# Elixir atom the caller passed and is what error messages
# reference, so a `:gas_price` validation error says ":gas_price",
# not "gasPrice".
%{}
|> maybe_put_address("from", :from, Map.get(params, :from))
|> maybe_put_address("to", :to, Map.get(params, :to))
|> maybe_put_uint_hex("value", :value, Map.get(params, :value))
|> maybe_put_uint_hex("gas", :gas, Map.get(params, :gas))
|> maybe_put_uint_hex("gasPrice", :gas_price, Map.get(params, :gas_price))
|> maybe_put_data("data", :data, Map.get(params, :data))
end
defp maybe_put_address(map, _wire_key, _user_key, nil), do: map
defp maybe_put_address(map, wire_key, user_key, value) do
Map.put(map, wire_key, validate_address!(user_key, value))
end
# Same standard as `Polymarket.Tx.validate_to/1`: 0x-prefixed, exactly
# 40 hex characters after the prefix (= 20 bytes), and the hex must
# actually decode. The pre-Phase-11c.1 implementation accepted any
# `"0x" <> binary`, which let placeholders like `"0xto"` flow onto
# the wire as wire-format addresses.
defp validate_address!(user_key, "0x" <> hex = full) when byte_size(hex) == 40 do
case Base.decode16(hex, case: :mixed) do
{:ok, _bytes} ->
full
:error ->
raise ArgumentError,
"#{inspect(user_key)} must be a 0x-prefixed 20-byte hex address, got: #{inspect(full)}"
end
end
defp validate_address!(user_key, other) do
raise ArgumentError,
"#{inspect(user_key)} must be a 0x-prefixed 20-byte hex address, got: #{inspect(other)}"
end
defp maybe_put_uint_hex(map, _wire_key, _user_key, nil), do: map
defp maybe_put_uint_hex(map, wire_key, _user_key, n) when is_integer(n) and n >= 0 do
Map.put(map, wire_key, "0x" <> hex_lower(n))
end
defp maybe_put_uint_hex(_map, _wire_key, user_key, other) do
raise ArgumentError,
"#{inspect(user_key)} must be a non-negative integer, got: #{inspect(other)}"
end
defp maybe_put_data(map, _wire_key, _user_key, nil), do: map
defp maybe_put_data(map, wire_key, _user_key, "0x" <> _ = hex) when is_binary(hex) do
Map.put(map, wire_key, hex)
end
defp maybe_put_data(map, wire_key, _user_key, bin) when is_binary(bin) do
Map.put(map, wire_key, "0x" <> Base.encode16(bin, case: :lower))
end
# Explicit fallback for non-binary `:data`. Without this clause,
# `RPC.estimate_gas(client, %{data: 123})` would raise
# `FunctionClauseError` while invalid addresses and integer fields
# raise `ArgumentError`. Harmonizing the error shape here means
# callers don't have to special-case data validation at the boundary.
defp maybe_put_data(_map, _wire_key, user_key, other) do
raise ArgumentError,
"#{inspect(user_key)} must be a binary or 0x-prefixed hex string, got: #{inspect(other)}"
end
defp hex_lower(n) when is_integer(n) and n >= 0 do
n |> Integer.to_string(16) |> String.downcase()
end
defp maybe_put(opts, _key, nil), do: opts
defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
end