defmodule Polymarket.ABI do @moduledoc """ Hand-rolled Solidity ABI primitives for the fixed on-chain surfaces in this SDK. This module covers exactly what `Polymarket.ERC20`, `Polymarket.CTF`, and `Polymarket.Collateral` need: function selectors, fixed-size types (`address`, `uint256`, `bytes32`), and the `uint256[]` dynamic-array tail. It is intentionally narrow — there is no general-purpose ABI decoder, no support for `bytes`/`string`/nested tuples, and no `ex_abi` / `ethereumex` / `ethers` framework dep (per `DECISIONS.md` #4 — extra dep weight previously cost the live bot ~10 seconds of startup latency). Calldata produced by these functions is validated byte-for-byte against ethers.js v6 fixtures in `test/fixtures/calldata.json`. ## Encoding rules summary * Function selector — first 4 bytes of `keccak256(signature)`. * `uint256` — 32-byte big-endian unsigned. * `address` — 20-byte address right-aligned in a 32-byte word (zero-padded on the left). * `bytes32` — 32 bytes inline. * `uint256[]` — dynamic. The function head emits a 32-byte offset pointer (the byte position of the array's tail relative to the start of the args, post-selector). The tail at that offset is `[length :: uint256][element0 :: uint256]…`. The caller is responsible for placing the offset in the head; this module provides `encode_uint256_array_tail/1` for the tail bytes. """ import Bitwise, only: [<<<: 2] @doc """ Returns the 4-byte function selector for a Solidity signature like `"approve(address,uint256)"`. """ @spec function_selector(String.t()) :: <<_::32>> def function_selector(signature) when is_binary(signature) do <> = ExKeccak.hash_256(signature) sel end @doc """ Encodes a non-negative integer as 32-byte big-endian unsigned. """ @spec encode_uint256(non_neg_integer()) :: <<_::256>> def encode_uint256(n) when is_integer(n) and n >= 0 and n < 1 <<< 256 do <> end @doc """ Encodes a 20-byte hex Ethereum address as a 32-byte right-aligned word (12 bytes of zero padding + 20 address bytes). Accepts the address with or without a leading `0x` and in any case. EIP-55 checksum is preserved on input but the output is the canonical Solidity representation (lowercase nibbles, byte-equivalent regardless of input case). """ @spec encode_address(String.t()) :: <<_::256>> def encode_address("0x" <> hex), do: encode_address(hex) def encode_address(hex) when is_binary(hex) and byte_size(hex) == 40 do case Base.decode16(hex, case: :mixed) do {:ok, addr_bytes} when byte_size(addr_bytes) == 20 -> <<0::96, addr_bytes::binary-size(20)>> _ -> raise ArgumentError, "address must be 20 bytes of hex, got: #{inspect(hex)}" end end def encode_address(other) do raise ArgumentError, "address must be 20 bytes of hex, got: #{inspect(other)}" end @doc """ Encodes a 32-byte hex value as 32 bytes inline. Accepts `0x`-prefixed or bare hex (any case). Used for `bytes32` parameters such as `parentCollectionId` and `conditionId`. """ @spec encode_bytes32(String.t()) :: <<_::256>> def encode_bytes32("0x" <> hex), do: encode_bytes32(hex) def encode_bytes32(hex) when is_binary(hex) and byte_size(hex) == 64 do case Base.decode16(hex, case: :mixed) do {:ok, bytes} when byte_size(bytes) == 32 -> bytes _ -> raise ArgumentError, "bytes32 must be 32 bytes of hex, got: #{inspect(hex)}" end end def encode_bytes32(other) do raise ArgumentError, "bytes32 must be 32 bytes of hex, got: #{inspect(other)}" end @doc """ Returns the dynamic tail of a `uint256[]` argument: a 32-byte length word followed by each element as 32-byte big-endian unsigned. The caller must separately place the head offset — typically `head_size` bytes (i.e. number of static head words × 32) — using `encode_uint256/1`. """ @spec encode_uint256_array_tail([non_neg_integer()]) :: binary() def encode_uint256_array_tail(values) when is_list(values) do length_word = encode_uint256(length(values)) elements = Enum.reduce(values, <<>>, fn n, acc -> acc <> encode_uint256(n) end) length_word <> elements end @doc """ Hex-encodes raw calldata for human display or fixture comparison. Always lowercase, always `0x`-prefixed. """ @spec to_hex(binary()) :: String.t() def to_hex(binary) when is_binary(binary) do "0x" <> Base.encode16(binary, case: :lower) end end