Packages

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
polymarket_sdk lib polymarket tx.ex
Raw

lib/polymarket/tx.ex

defmodule Polymarket.Tx do
@moduledoc """
Pure EIP-155 legacy (type-0) Ethereum transaction serialization and
signing.
This module covers exactly the surface needed to produce signed raw
transaction bytes from caller-provided fields. There is no
JSON-RPC, no nonce/gas fetching, and no submission. Callers are
expected to broadcast the bytes through their own client.
## Scope
* Legacy type-0 transactions only (RLP-encoded list of 9 fields).
* EIP-155 chain-id-mixed signature (`v = chain_id * 2 + 35 + yParity`).
* No EIP-1559 type-2 (`max_priority_fee_per_gas` / `max_fee_per_gas`).
Polymarket on Polygon transactions today are routinely sent as
type-0 with `gasPrice`, so this is sufficient for the planned
CTF / pUSD on-chain helpers. Type-2 support is deferred until
a real consumer needs it.
* Keccak-256 message hashing via `ExKeccak` and signing via
`ExSecp256k1.sign_compact/2` — matches `polymarket_clob`'s
EIP-712 signing path.
## Output contract
`serialize/1` and `hash/1` operate on a `Polymarket.Tx` struct that
has its `:v`, `:r`, `:s` fields populated. Call `sign/2` to fill
them; calling `serialize/1` on an unsigned tx raises
`ArgumentError`.
Output is byte-for-byte verified against `ethers.Transaction` in
`test/fixtures/tx.json` § legacy_transactions.
## Fields
* `:nonce` — non-negative integer.
* `:gas_price` — non-negative integer (wei).
* `:gas_limit` — non-negative integer (units of gas).
* `:to` — `0x`-prefixed 20-byte hex address, or `nil` for
contract creation.
* `:value` — non-negative integer (wei).
* `:data` — raw bytes (binary). Pass `<<>>` (or `nil`) for empty
data.
* `:chain_id` — positive integer (137 for Polygon, 1 for Ethereum
mainnet).
* `:v`, `:r`, `:s` — populated by `sign/2`. `nil` on an unsigned
tx.
"""
alias Polymarket.RLP
@type t :: %__MODULE__{
nonce: non_neg_integer(),
gas_price: non_neg_integer(),
gas_limit: non_neg_integer(),
to: String.t() | nil,
value: non_neg_integer(),
data: binary(),
chain_id: pos_integer(),
v: non_neg_integer() | nil,
r: non_neg_integer() | nil,
s: non_neg_integer() | nil
}
defstruct [
:nonce,
:gas_price,
:gas_limit,
:to,
:chain_id,
:v,
:r,
:s,
value: 0,
data: <<>>
]
@known_opts [:nonce, :gas_price, :gas_limit, :to, :value, :data, :chain_id]
@doc """
Builds a `Polymarket.Tx` from required keyword fields.
Required: `:nonce`, `:gas_price`, `:gas_limit`, `:chain_id`.
Optional: `:to` (default `nil` — contract creation), `:value`
(default `0`), `:data` (default `<<>>`).
Raises `KeyError` if a required key is missing, and `ArgumentError`
if an **unknown** key is passed. Unknown keys are rejected — rather
than silently ignored — because a typo in `:to` (e.g. `too:` /
`recipient:`) would otherwise leave `:to` at its `nil` default and
sign an unintended *contract-creation* transaction.
"""
@spec new(keyword()) :: t()
def new(opts) do
reject_unknown_opts!(opts)
%__MODULE__{
nonce: fetch_non_neg!(opts, :nonce),
gas_price: fetch_non_neg!(opts, :gas_price),
gas_limit: fetch_non_neg!(opts, :gas_limit),
to: validate_to(Keyword.get(opts, :to)),
value: validate_non_neg(Keyword.get(opts, :value, 0), :value),
data: validate_data(Keyword.get(opts, :data, <<>>)),
chain_id: fetch_pos!(opts, :chain_id)
}
end
defp reject_unknown_opts!(opts) do
case Keyword.keys(opts) -- @known_opts do
[] ->
:ok
unknown ->
raise ArgumentError,
"unknown option(s) for Polymarket.Tx.new/1: #{inspect(unknown)}. " <>
"Allowed keys: #{inspect(@known_opts)}"
end
end
@doc """
Returns the RLP-encoded **unsigned** signing payload — the 9-field
list `[nonce, gas_price, gas_limit, to, value, data, chain_id, 0,
0]`. This is the input to `signing_hash/1`.
"""
@spec signing_payload(t()) :: binary()
def signing_payload(%__MODULE__{} = tx) do
RLP.encode_list([
RLP.encode_integer(tx.nonce),
RLP.encode_integer(tx.gas_price),
RLP.encode_integer(tx.gas_limit),
encode_to(tx.to),
RLP.encode_integer(tx.value),
RLP.encode_bytes(tx.data || <<>>),
RLP.encode_integer(tx.chain_id),
RLP.encode_integer(0),
RLP.encode_integer(0)
])
end
@doc """
Returns `keccak256(signing_payload(tx))` — the 32-byte digest that
is signed under EIP-155.
"""
@spec signing_hash(t()) :: <<_::256>>
def signing_hash(%__MODULE__{} = tx) do
ExKeccak.hash_256(signing_payload(tx))
end
@doc """
Signs the transaction using a 32-byte private key (raw binary or
`0x`-prefixed hex). Returns a new `Polymarket.Tx` with `:v`, `:r`,
`:s` populated.
`:v` follows EIP-155: `chain_id * 2 + 35 + yParity`, where `yParity`
is the secp256k1 recovery id (0 or 1).
"""
@spec sign(t(), binary() | String.t()) :: t()
def sign(%__MODULE__{} = tx, private_key) do
pk = decode_private_key!(private_key)
digest = signing_hash(tx)
case ExSecp256k1.sign_compact(digest, pk) do
{:ok, {<<r::unsigned-big-256, s::unsigned-big-256>>, recovery_id}} ->
v = tx.chain_id * 2 + 35 + recovery_id
%__MODULE__{tx | v: v, r: r, s: s}
{:error, reason} ->
raise RuntimeError, "secp256k1 signing failed: #{inspect(reason)}"
end
end
@doc """
Returns the RLP-encoded **signed** raw transaction bytes — the
9-field list `[nonce, gas_price, gas_limit, to, value, data, v, r,
s]`.
Raises `ArgumentError` if `tx` has not been signed.
"""
@spec serialize(t()) :: binary()
def serialize(%__MODULE__{v: v, r: r, s: s} = tx)
when is_integer(v) and is_integer(r) and is_integer(s) do
RLP.encode_list([
RLP.encode_integer(tx.nonce),
RLP.encode_integer(tx.gas_price),
RLP.encode_integer(tx.gas_limit),
encode_to(tx.to),
RLP.encode_integer(tx.value),
RLP.encode_bytes(tx.data || <<>>),
RLP.encode_integer(v),
RLP.encode_integer(r),
RLP.encode_integer(s)
])
end
def serialize(%__MODULE__{}) do
raise ArgumentError, "transaction has not been signed; call sign/2 first"
end
@doc """
Returns `keccak256(serialize(tx))` — the on-chain transaction hash.
"""
@spec hash(t()) :: <<_::256>>
def hash(%__MODULE__{} = tx) do
ExKeccak.hash_256(serialize(tx))
end
# ── Private ──
defp encode_to(nil), do: RLP.encode_bytes(<<>>)
defp encode_to("0x" <> hex), do: encode_to(hex)
defp encode_to(hex) when is_binary(hex) and byte_size(hex) == 40 do
case Base.decode16(hex, case: :mixed) do
{:ok, addr} when byte_size(addr) == 20 -> RLP.encode_bytes(addr)
_ -> raise ArgumentError, "to address must be 20 bytes of hex"
end
end
defp encode_to(other) do
raise ArgumentError, "to address must be nil or 20 bytes of hex, got: #{inspect(other)}"
end
defp decode_private_key!(<<_::binary-size(32)>> = pk), do: pk
defp decode_private_key!("0x" <> hex), do: decode_private_key!(hex)
defp decode_private_key!(hex) when is_binary(hex) and byte_size(hex) == 64 do
case Base.decode16(hex, case: :mixed) do
{:ok, pk} when byte_size(pk) == 32 -> pk
# SECURITY: do not include the key material in the message.
_ -> raise ArgumentError, "invalid private key: expected a 32-byte binary or 64 hex digits"
end
end
defp decode_private_key!(_other) do
# SECURITY: never interpolate/inspect the key value here — an
# almost-valid key (trailing whitespace, 63/65 hex chars, "0X"
# casing, 31/33 raw bytes) would otherwise leak secret material
# into the exception message, and from there into Logger / crash
# dumps / Sentry. Report only the expected shape, never the value.
raise ArgumentError, "invalid private key: expected a 32-byte binary or 64 hex digits"
end
defp fetch_non_neg!(opts, key) do
validate_non_neg(Keyword.fetch!(opts, key), key)
end
defp fetch_pos!(opts, key) do
case Keyword.fetch!(opts, key) do
n when is_integer(n) and n > 0 -> n
n -> raise ArgumentError, "#{inspect(key)} must be a positive integer, got: #{inspect(n)}"
end
end
defp validate_non_neg(n, _key) when is_integer(n) and n >= 0, do: n
defp validate_non_neg(other, key) do
raise ArgumentError, "#{inspect(key)} must be a non-negative integer, got: #{inspect(other)}"
end
defp validate_to(nil), do: nil
defp validate_to("0x" <> hex = full) when byte_size(hex) == 40 do
# Length alone is not enough — `"0x" <> String.duplicate("zz", 20)`
# also has byte_size 40. Decode the hex eagerly so a malformed
# address fails fast in `Tx.new/1` rather than later inside
# `encode_to/1` from `signing_payload/1` or `serialize/1`.
case Base.decode16(hex, case: :mixed) do
{:ok, _bytes} ->
full
:error ->
raise ArgumentError,
"to must be nil or a 0x-prefixed 20-byte hex address, got: #{inspect(full)}"
end
end
defp validate_to(other) do
raise ArgumentError,
"to must be nil or a 0x-prefixed 20-byte hex address, got: #{inspect(other)}"
end
defp validate_data(nil), do: <<>>
defp validate_data("0x" <> hex) do
case Base.decode16(hex, case: :mixed) do
{:ok, bytes} -> bytes
:error -> raise ArgumentError, "data hex string is malformed"
end
end
defp validate_data(bin) when is_binary(bin), do: bin
defp validate_data(other) do
raise ArgumentError, "data must be a binary or 0x-prefixed hex string, got: #{inspect(other)}"
end
end