PositionClient (include/polymarket/position_client.hpp) splits, merges and redeems
binary market positions on Polygon, matching split_position,
merge_positions, merge_multiple_positions and redeem_positions in the
official Python SDK.
| Operation | Effect |
|---|---|
split_position(condition_id, amount) |
Lock amount pUSD into amount YES + amount NO shares |
merge_positions(condition_id, amount = "max") |
Burn YES+NO pairs back into pUSD; "max" merges min(YES, NO) |
merge_multiple_positions(requests) |
Merge several conditions in one call |
redeem_positions(condition_id) |
Redeem every position held in a closed, resolved market |
Amounts are integer base units as base-10 strings. pUSD and outcome shares
both use 6 decimals, so "1000000" is 1 pUSD or 1 share.
condition_id is 0x plus 32 bytes of hex. A Protocol V2 market may also be
given by its 31-byte (bytes31) id; it is passed to Gamma as given.
#include "polymarket/position_client.hpp"
polymarket::PositionClientConfig config;
config.private_key = std::getenv("PRIVATE_KEY");
config.rpc_url = std::getenv("POLYGON_RPC_ENDPOINT");
// Polymarket account wallet (Gnosis Safe), gasless through the relayer:
config.wallet_type = polymarket::SignatureType::POLY_GNOSIS_SAFE;
config.funder_address = std::getenv("POLYMARKET_PROXY_ADDRESS"); // optional check
config.relayer_api_key = std::getenv("RELAYER_API_KEY");
polymarket::PositionClient client(config);
auto handle = client.redeem_positions("0x6b04...6fbc");
auto outcome = handle.wait(); // throws on revert, relayer failure or timeoutwallet_type |
Holder | Sent by | Gas |
|---|---|---|---|
EOA (default) |
the key's address | signed legacy (EIP-155) transaction via rpc_url |
POL from the EOA |
POLY_GNOSIS_SAFE |
the key's Polymarket Safe | Safe transaction via relayer-v2.polymarket.com |
paid by the relayer |
The Safe address is derived from the key with CREATE2. funder_address is
optional; if it is set and is not that Safe, the constructor throws. The
Polymarket profile API calls this wallet proxyWallet even though it is a
Safe, not a POLY_PROXY wallet. POLY_PROXY and POLY_1271 deposit wallets
are not supported yet and are rejected.
The relayer needs a relayer API key and its address
(relayer_api_key_address, default: the signer address). rpc_url is still
required for a Safe: it is used for balance reads and receipts.
- The market is looked up on Gamma (
/markets?condition_ids=...; redeem addsclosed=true). Gamma omits closed markets otherwise, so split and merge only work on open markets. - The market's
versionpicks the contract:v1(CTF) markets use the pUSD collateral adapter, or the neg-risk collateral adapter whennegRiskis true, with the ConditionalTokens ABI (splitPosition,mergePositions,redeemPositions, partition[1, 2]).v2markets use the Protocol V2 router (split,merge,redeem(bytes31, outcomeIndex, amount)).
- Merge and redeem read the wallet's balances with
balanceOfBatchfirst. Merge rejects amounts above min(YES, NO); redeem rejects a wallet with no balance instead of spending gas on an empty redeem. - The calls are sent:
- EOA: chain id check, pending nonce,
eth_gasPrice,eth_estimateGas(a revert such as a missing approval fails here, before anything is sent), sign,eth_sendRawTransaction. - Safe: relayer nonce, EIP-712
SafeTxdigest signed as aneth_signmessage (v + 4),POST /submit. Rate limits, a busy wallet and stale nonces are retried with a fresh nonce and signature.
- EOA: chain id check, pending nonce,
All contract addresses come from PolymarketContracts (polygon_mainnet() by
default), which can be overridden field by field.
merge_multiple_positions, and a V2 redeem with both outcomes held, produce
several calls:
- Safe: one atomic transaction (a
DELEGATECALLinto Safe MultiSend). - EOA: one transaction per call, each mined before the next is sent. This
is not atomic: earlier merges stay done and later ones are not sent.
handle.transaction_hashes()lists every transaction sent. If a call fails after an earlier one reached the node (a revert, a failed gas estimate, an RPC error),PartialBatchErroris thrown:submitted_hashes()lists the transactions already sent,failed_call_index()the call that failed, andcause()holds the original exception (for exampleTransactionRevertedError).
Each operation returns a TransactionHandle as soon as the node or relayer
accepts the transaction.
-
transaction_hash(): empty for a relayer transaction until it is mined. -
transaction_id(): the relayer id (Safe only). -
wait(timeout = 3 min, poll = 2 s): returns aTransactionOutcomewith the receipt, or throws:TransactionRevertedError: mined with status 0.TransactionFailedError: the relayer reportedSTATE_FAILEDorSTATE_INVALID.TransactionTimeoutError: no outcome in time; it may still be mined.
Polling never sleeps past the deadline, and the last poll happens at the deadline. So a timeout shorter than
pollstill waits the full timeout:wait(1s, 2s)polls at 0 s and at 1 s before giving up.
Invalid input throws std::invalid_argument; RPC, Gamma and relayer failures
throw std::runtime_error. An EOA batch that fails after sending part of its
calls throws PartialBatchError (see Batches and atomicity).
Operations do not check approvals. Before the first operation a wallet needs:
- split: a pUSD
approvefor the operator contract (collateral adapter, neg-risk collateral adapter or V2 router); - merge and redeem:
setApprovalForAllon the position token (ConditionalTokens or the V2 position manager) for the same operator.
For an EOA, a missing approval shows up as a revert during gas estimation, before anything is sent. For a Safe it surfaces only after submission, as a failed relayer transaction.
setup_trading_approvals() grants, once per wallet, the same set as the
official SDKs' setup_trading_approvals (required_trading_approvals(contracts)
in include/polymarket/trading_approvals.hpp): 17 approvals that cover CLOB
trading as well as split, merge and redeem.
| Token | Approval | Spenders / operators |
|---|---|---|
pUSD collateral_token |
approve(spender, 2^256 - 1) |
standard and neg-risk exchanges, both collateral adapters, V2 router, exchange V3, perps deposit contract |
| ConditionalTokens | setApprovalForAll(operator, true) |
standard and neg-risk exchanges, both collateral adapters, auto-redeem operator, binary module, neg-risk module |
V2 position_manager |
setApprovalForAll(operator, true) |
V2 router, exchange V3, auto-redeem operator |
auto state = client.get_trading_approvals_state(); // or (other_wallet)
if (!state.is_fully_approved)
client.setup_trading_approvals(); // sends only state.missing, then waitsget_trading_approvals_state(wallet = this wallet)reads every approval witheth_call(allowance,isApprovedForAll) and lists only the missing ones. An allowance below2^256 - 1counts as missing, as in the official SDKs. The official SDKs read this from the Data API (/v2/approvals), which can lag; this client always reads the chain.setup_trading_approvals(timeout = 3 min)sendsapprovecalls first, thensetApprovalForAll, and waits. A Safe sends them as one atomic MultiSend transaction; an EOA sends one transaction per approval (not atomic, see Batches and atomicity; rerunning picks up where it stopped). It returnsstd::nulloptwithout sending anything when the wallet is already fully approved.
Single approvals and transfers return a TransactionHandle without waiting:
| Method | Call |
|---|---|
approve_erc20(token, spender, amount) |
ERC-20 approve; amount is a uint256 in base units or "max", "0" revokes |
approve_erc1155_for_all(token, operator, approved = true) |
ERC-1155 setApprovalForAll |
transfer_erc20(token, recipient, amount) |
ERC-20 transfer of a positive base-unit amount |
Each also takes an optional relayer metadata string.
# Trading approvals: list the missing ones, then grant them
./build/approvals_example
./build/approvals_example --execute
# Dry run: resolve the market and print balances
./build/position_example redeem 0x6b04...6fbc
# Send it
./build/position_example redeem 0x6b04...6fbc --execute
./build/position_example split 0x... 1000000 --execute
./build/position_example merge 0x... max --execute
# Protocol V2 markets also accept the 31-byte condition id
./build/position_example split 0x01...44 1000000 --executeThe examples read PRIVATE_KEY and POLYGON_RPC_ENDPOINT. With
POLYMARKET_PROXY_ADDRESS set it uses the Safe and the relayer
(RELAYER_API_KEY, optional RELAYER_API_KEY_ADDRESS); otherwise it sends
from the EOA.
- Lookup by market id or position id, and combo (multi-leg) positions.
POLY_PROXYand deposit-wallet (POLY_1271) relayer paths.- Reading approvals from the Data API (
/v2/approvals); state is read on chain.
- Calldata, signed transactions, Safe address derivation, SafeTx digests,
signatures, MultiSend packing and relayer payloads match vectors produced
by the official Python SDK (
test_evm_abi,test_evm_abi_shapes,test_evm_transaction,test_position_calls,test_safe_relayer). test_position_clientandtest_safe_relayer_flowsrun EOA and Safe flows;test_trading_approvalsruns approval reads, setup (EOA and Safe MultiSend), single approvals and transfers;test_approval_callschecks approval and transfer calldata against the py-sdk golden vectors;test_transaction_waiterscovers receipt and relayer waits whose timeout is shorter than the poll interval.- These flows run against local fake RPC, Gamma and relayer servers.
- Against Polygon mainnet (read-only): the SafeTx digest equals the Safe's own
getTransactionHash, the signature passes the Safe'scheckSignatures, and a simulatedexecTransactionredeem succeeds. - Against Polygon mainnet with real funds:
- A Safe split and merge of 1 base unit went through the relayer.
wait(1s, 2s)on the pending split timed out after about 1.3 s. - An EOA batch whose second call fails gas estimation threw
PartialBatchErrorwith the mined hash of the first call. setup_trading_approvalson a Safe that lacked only the perps deposit allowance sent that oneapprovethrough the relayer; a freshget_trading_approvals_statethen reported the Safe fully approved.- No Protocol V2 market was listed on Gamma yet, so 31-byte ids were only checked against fixtures from the official Python SDK.
- A Safe split and merge of 1 base unit went through the relayer.