defmodule Polymarket.CTF do @moduledoc """ Gnosis Conditional Tokens Framework (CTF) operations Polymarket relies on. Three functions are exposed in two flavors each — a pure calldata builder and a submit wrapper. The pair always shares the same function-specific arguments; only the wrapper layers signing and broadcasting on top. ## Two surfaces * **Calldata builders** — `redeem_positions_calldata/4`, `merge_positions_calldata/5`, `split_position_calldata/5`. Produce the raw function-call bytes. Use when you want the bytes for inspection, off-line signing, or submission through your own Polygon RPC client. * **Submit wrappers** — `redeem_positions/2`, `merge_positions/2`, `split_position/2`. Build, sign, and broadcast in one call. Compose the calldata builder + `Polymarket.Tx` + `Polymarket.RPC`. Caller provides every signing field (`:nonce`, `:gas_price`, `:gas_limit`, `:chain_id`, `:private_key`); the wrappers do not auto-fetch from RPC. ## What each function does * `redeem_positions[_calldata]` — claim payouts for resolved outcome tokens. Per-outcome amounts are derived from the reported payout numerators on-chain; the caller only specifies the partition. * `merge_positions[_calldata]` — combine outcome tokens back into collateral. Useful for wallet hygiene or when liquidating an entire spread. * `split_position[_calldata]` — mint outcome tokens from collateral. Rare in normal trading; included for completeness. CTF function ABI (from the Gnosis spec): * `redeemPositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets)` * `mergePositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] partition, uint256 amount)` * `splitPosition(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] partition, uint256 amount)` ## Partition / index sets For Polymarket binary YES/NO markets, pass `[1, 2]`. For an N-outcome market, pass `[1, 2, 4, ..., 2^(N-1)]` — each integer is a bitmask selecting one outcome slot. ## Static-vs-dynamic ABI layout All three functions have exactly one dynamic parameter (`uint256[]`). Per the Solidity ABI, every argument occupies one 32-byte word in the head — static args inline their value, dynamic args inline an offset pointer to their tail. `redeemPositions` (`address, bytes32, bytes32, uint256[]`) has 4 head words = `4 * 32 = 128` bytes: 1 address + 2 bytes32 + 1 dynamic-array offset. `mergePositions` and `splitPosition` (`address, bytes32, bytes32, uint256[], uint256`) have 5 head words = `5 * 32 = 160` bytes: 1 address + 2 bytes32 + 1 dynamic-array offset + 1 uint256. The dynamic-array offset placed in the head equals the head size (i.e. the byte position of the array tail relative to the start of the args, post-selector). Calldata bytes are validated against the canonical ethers.js v6 encoder in `test/fixtures/calldata.json`. The submit-wrapper composition (calldata → tx → signed bytes) is verified against a manually-composed reference in `test/polymarket/submit_test.exs`. """ alias Polymarket.ABI @redeem_signature "redeemPositions(address,bytes32,bytes32,uint256[])" @merge_signature "mergePositions(address,bytes32,bytes32,uint256[],uint256)" @split_signature "splitPosition(address,bytes32,bytes32,uint256[],uint256)" # redeem head: 1 address + 2 bytes32 + 1 dynamic-array offset = 4 words. @redeem_head_size 4 * 32 # merge/split head: 1 address + 2 bytes32 + 1 dynamic-array offset # + 1 uint256 = 5 words. @merge_split_head_size 5 * 32 @doc """ Builds calldata for `redeemPositions(address, bytes32, bytes32, uint256[])`. `index_sets` is the partition over outcome slots, expressed as bitmasks. For Polymarket binary YES/NO markets pass `[1, 2]`. """ @spec redeem_positions_calldata( String.t(), String.t(), String.t(), [non_neg_integer()] ) :: binary() def redeem_positions_calldata( collateral_token, parent_collection_id, condition_id, index_sets ) when is_list(index_sets) do head = ABI.encode_address(collateral_token) <> ABI.encode_bytes32(parent_collection_id) <> ABI.encode_bytes32(condition_id) <> ABI.encode_uint256(@redeem_head_size) tail = ABI.encode_uint256_array_tail(index_sets) ABI.function_selector(@redeem_signature) <> head <> tail end @doc """ Builds calldata for `mergePositions(address, bytes32, bytes32, uint256[], uint256)`. """ @spec merge_positions_calldata( String.t(), String.t(), String.t(), [non_neg_integer()], non_neg_integer() ) :: binary() def merge_positions_calldata( collateral_token, parent_collection_id, condition_id, partition, amount ) when is_list(partition) and is_integer(amount) and amount >= 0 do head = ABI.encode_address(collateral_token) <> ABI.encode_bytes32(parent_collection_id) <> ABI.encode_bytes32(condition_id) <> ABI.encode_uint256(@merge_split_head_size) <> ABI.encode_uint256(amount) tail = ABI.encode_uint256_array_tail(partition) ABI.function_selector(@merge_signature) <> head <> tail end @doc """ Builds calldata for `splitPosition(address, bytes32, bytes32, uint256[], uint256)`. """ @spec split_position_calldata( String.t(), String.t(), String.t(), [non_neg_integer()], non_neg_integer() ) :: binary() def split_position_calldata( collateral_token, parent_collection_id, condition_id, partition, amount ) when is_list(partition) and is_integer(amount) and amount >= 0 do head = ABI.encode_address(collateral_token) <> ABI.encode_bytes32(parent_collection_id) <> ABI.encode_bytes32(condition_id) <> ABI.encode_uint256(@merge_split_head_size) <> ABI.encode_uint256(amount) tail = ABI.encode_uint256_array_tail(partition) ABI.function_selector(@split_signature) <> head <> tail end # ── Submit wrappers ── @doc """ Builds, signs, and broadcasts a `redeemPositions(...)` transaction to the CTF contract at `:contract`. Returns the on-chain transaction hash on success. Required `opts`: `:contract`, `:collateral_token`, `:parent_collection_id`, `:condition_id`, `:index_sets`, plus the common signing fields `:nonce`, `:gas_price`, `:gas_limit`, `:chain_id`, `:private_key`. See `redeem_positions_calldata/4` for argument shapes. """ @spec redeem_positions(Polymarket.RPC.t(), keyword()) :: {:ok, String.t()} | {:error, term()} def redeem_positions(%Polymarket.RPC{} = rpc, opts) when is_list(opts) do required = [:contract, :collateral_token, :parent_collection_id, :condition_id, :index_sets] with :ok <- Polymarket.Submit.require_opts(opts, required) do calldata = redeem_positions_calldata( Keyword.fetch!(opts, :collateral_token), Keyword.fetch!(opts, :parent_collection_id), Keyword.fetch!(opts, :condition_id), Keyword.fetch!(opts, :index_sets) ) Polymarket.Submit.send_call(rpc, Keyword.fetch!(opts, :contract), calldata, opts) end end @doc """ Builds, signs, and broadcasts a `mergePositions(...)` transaction to the CTF contract at `:contract`. Required `opts`: `:contract`, `:collateral_token`, `:parent_collection_id`, `:condition_id`, `:partition`, `:amount`, plus `:nonce`, `:gas_price`, `:gas_limit`, `:chain_id`, `:private_key`. """ @spec merge_positions(Polymarket.RPC.t(), keyword()) :: {:ok, String.t()} | {:error, term()} def merge_positions(%Polymarket.RPC{} = rpc, opts) when is_list(opts) do required = [:contract, :collateral_token, :parent_collection_id, :condition_id, :partition, :amount] with :ok <- Polymarket.Submit.require_opts(opts, required) do calldata = merge_positions_calldata( Keyword.fetch!(opts, :collateral_token), Keyword.fetch!(opts, :parent_collection_id), Keyword.fetch!(opts, :condition_id), Keyword.fetch!(opts, :partition), Keyword.fetch!(opts, :amount) ) Polymarket.Submit.send_call(rpc, Keyword.fetch!(opts, :contract), calldata, opts) end end @doc """ Builds, signs, and broadcasts a `splitPosition(...)` transaction to the CTF contract at `:contract`. Required `opts`: same as `merge_positions/2`. """ @spec split_position(Polymarket.RPC.t(), keyword()) :: {:ok, String.t()} | {:error, term()} def split_position(%Polymarket.RPC{} = rpc, opts) when is_list(opts) do required = [:contract, :collateral_token, :parent_collection_id, :condition_id, :partition, :amount] with :ok <- Polymarket.Submit.require_opts(opts, required) do calldata = split_position_calldata( Keyword.fetch!(opts, :collateral_token), Keyword.fetch!(opts, :parent_collection_id), Keyword.fetch!(opts, :condition_id), Keyword.fetch!(opts, :partition), Keyword.fetch!(opts, :amount) ) Polymarket.Submit.send_call(rpc, Keyword.fetch!(opts, :contract), calldata, opts) end end end