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, {<>, 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