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