This bot redeems winning positions on Polymarket across multiple accounts. When you win a bet on Polymarket, you need to "redeem" (claim) your winnings. This bot automates that process, handling both regular and neg-risk markets, and uses batch transactions to save ~50% on gas fees.
When a Polymarket market resolves (the event happens and the outcome is known), winning shares need to be "redeemed" to get your money back. This is a blockchain transaction that:
Each redemption requires a transaction on the Polygon blockchain, which costs MATIC (gas).
| Market Type | Contract | How to Redeem |
|---|---|---|
| Regular | CTF (Conditional Token Framework) | CTF.redeemPositions() |
| Neg-Risk | NegRiskAdapter | NegRiskAdapter.redeemPositions() |
Neg-risk markets are multi-outcome markets (e.g., "Who wins the election?" with 5+ candidates). They use a different smart contract for redemption. The bot detects which type each position is and handles them automatically.
The bot works with Gnosis Safe accounts - multi-signature wallets commonly used for DeFi. Each account has a "Safe" (the wallet contract) and an "EOA" (externally owned account) that controls it.
Instead of sending 10 separate transactions (one per redemption), the bot packs all redemptions into one MultiSend transaction. This saves ~50% on gas because you pay the fixed transaction overhead only once.
def sanitize_error(error_msg):
# Remove wallet addresses from error messages
msg = re.sub(r'0x[a-fA-F0-9]{40}', '[HIDDEN]', msg)
msg = re.sub(r'0x[a-fA-F0-9]{64}', '[HIDDEN]', msg)
return msg[:200]
What it does: Strips wallet addresses and transaction hashes from error messages. This prevents accidentally leaking sensitive information in logs or terminal output.
def get_redeemable(wallet):
url = f"https://data-api.polymarket.com/positions?user={wallet}"
r = requests.get(url, timeout=15)
# Only return positions that are redeemable AND have value
return [p for p in r.json()
if p.get('redeemable')
and float(p.get('currentValue', 0)) > 0]
What it does: Calls the Polymarket API to find all positions for a wallet that are: (1) resolved and ready to redeem, and (2) actually have value (worth claiming).
def encode_redeem_regular(condition_id):
# Build the data payload for a CTF.redeemPositions call
selector = Web3.keccak(text="redeemPositions(address,bytes32,...)")[:4]
args = encode(
['address', 'bytes32', 'bytes32', 'uint256[]'],
[USDC, bytes(32), condition_id, [1, 2]]
)
return selector + args
What it does: Encodes the smart contract function call as raw bytes. This is how you tell the blockchain "I want to call the redeemPositions function with these specific arguments." The selector identifies which function to call, and the args are the parameters.
def batch_redeem(w3, pk, safe_addr, positions):
# 1. Build all redeem calls (route to correct contract)
calls = []
for pos in positions:
if pos.get('negativeRisk'):
calls.append({# route to NegRiskAdapter})
else:
calls.append({# route to CTF})
# 2. Pack all calls into MultiSend format
multisend_data = encode_multisend(calls)
# 3. Sign and execute through Gnosis Safe
safe.functions.execTransaction(
MULTISEND, 0, multisend_data,
1, # DELEGATECALL for MultiSend
..., sig
)
What it does: This is the main function. It (1) builds the list of redemption calls, routing each to the correct smart contract, (2) packs them all into a single MultiSend transaction, (3) signs it with your private key, and (4) sends it to the blockchain.
Each individual call is packed as: operation (1 byte) + to_address (20 bytes) + value (32 bytes) + data_length (32 bytes) + data
All calls are concatenated into one blob and sent to the MultiSend contract, which executes them one by one in a single transaction.
| Method | 10 Redemptions | Gas Cost |
|---|---|---|
| Individual transactions | 10 separate txs | ~0.008 MATIC total |
| Batch (MultiSend) | 1 transaction | ~0.004 MATIC total |
| Savings | ~50% less gas |
pip install web3 eth-account eth-abi requests python-dotenv
Required in .env (one set per account):
PRIVATE_KEY_MAY13=0x...
PUBLIC_KEY_MAY13=0x...
PRIVATE_KEY_DEC11=0x...
PUBLIC_KEY_DEC11=0x...
# ... etc for each account
| Term | Meaning |
|---|---|
| Redeem | Claiming winnings from a resolved market by burning winning shares |
| Gas | The fee paid to process a blockchain transaction (paid in MATIC on Polygon) |
| MultiSend | A contract that executes multiple operations in a single transaction |
| Gnosis Safe | A multi-signature wallet contract for secure fund management |
| EOA | Externally Owned Account - a regular wallet controlled by a private key |
| Neg-Risk | Multi-outcome markets on Polymarket using a special contract adapter |
| CTF | Conditional Token Framework - Polymarket's core token contract |
| ABI Encoding | Formatting function calls as raw bytes for blockchain transactions |
| DELEGATECALL | A special call type that runs another contract's code in the caller's context |
# --- PYTHON ---
def get_redeemable(wallet):
url = f"https://data-api.polymarket.com/positions?user={wallet}"
r = requests.get(url, timeout=15)
if r.status_code == 200:
return [p for p in r.json()
if p.get('redeemable') and float(p.get('currentValue', 0)) > 0]
return []
# --- PSEUDO-CODE ---
FUNCTION get_redeemable(wallet address):
CALL the Polymarket data API:
"Give me ALL positions for this wallet"
FILTER the results to keep ONLY:
Positions where 'redeemable' is True (market has resolved)
AND the position has a value greater than $0
RETURN the filtered list of redeemable positions
# --- PYTHON ---
def encode_redeem_regular(condition_id):
selector = Web3.keccak(text="redeemPositions(address,bytes32,bytes32,uint256[])")[:4]
cid = condition_id[2:] if condition_id.startswith('0x') else condition_id
args = encode(['address', 'bytes32', 'bytes32', 'uint256[]'],
[USDC, bytes(32), bytes.fromhex(cid), [1, 2]])
return selector + args
# --- PSEUDO-CODE ---
FUNCTION encode_redeem_regular(condition ID):
CALCULATE the function selector:
Hash the function name "redeemPositions(...)" using keccak256
Take the first 4 bytes (this identifies which function to call)
ENCODE the arguments as raw bytes:
Argument 1: The USDC token address (which token to receive)
Argument 2: Empty 32 bytes (parent collection ID)
Argument 3: The condition ID (which market to redeem)
Argument 4: [1, 2] (redeem both outcomes: YES and NO)
COMBINE: selector + encoded arguments
RETURN the raw bytes (this is what gets sent to the blockchain)
# --- PYTHON ---
def batch_redeem(w3, pk, safe_addr, positions):
calls = []
for pos in positions:
if pos.get('negativeRisk'):
token_bal = get_token_balance(w3, safe_addr, pos.get('asset'))
calls.append({'to': NEG_RISK_ADAPTER,
'data': encode_redeem_neg_risk(cid, token_bal, outcome_index)})
else:
calls.append({'to': CTF,
'data': encode_redeem_regular(cid)})
multisend_data = encode_multisend(calls)
nonce = safe.functions.nonce().call()
tx_hash = safe.functions.getTransactionHash(MULTISEND, 0, multisend_data, 1, ...).call()
signed = account.signHash(tx_hash)
sig = signed.r.to_bytes(32) + signed.s.to_bytes(32) + bytes([signed.v])
tx = safe.functions.execTransaction(..., sig).build_transaction(tx_params)
estimated_gas = w3.eth.estimate_gas(tx)
tx['gas'] = int(estimated_gas * 1.2)
signed_tx = w3.eth.account.sign_transaction(tx, pk)
tx_hash = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=60)
return receipt['status'] == 1, tx_hash.hex(), gas_cost
# --- PSEUDO-CODE ---
FUNCTION batch_redeem(web3 connection, private key, safe address, list of positions):
STEP 1 - Build all the redemption calls:
FOR each winning position:
CHECK: is this a neg-risk market or regular market?
IF neg-risk:
GET the token balance for this position
CREATE a call to the NegRiskAdapter contract
IF regular:
CREATE a call to the CTF contract
STEP 2 - Pack everything into one MultiSend:
COMBINE all calls into a single data blob using MultiSend format:
For each call: operation byte + destination address + value + data
CONCATENATE them all together
STEP 3 - Sign the transaction:
GET the current nonce (transaction counter) from the Safe contract
CALCULATE the transaction hash (what we're authorizing)
SIGN it with our private key
COMBINE signature components (r + s + v) into one signature
STEP 4 - Send to blockchain:
BUILD the full transaction with gas parameters
ESTIMATE how much gas it will need (+ 20% safety buffer)
SIGN the transaction
SEND it to the Polygon network
WAIT up to 60 seconds for confirmation
STEP 5 - Report results:
CHECK if the transaction succeeded (status == 1)
CALCULATE actual gas cost in MATIC
RETURN success/failure, transaction hash, and gas cost
# --- PYTHON ---
def process_account(w3, account_suffix):
pk = os.getenv(f"PRIVATE_KEY{account_suffix}")
safe = os.getenv(f"PUBLIC_KEY{account_suffix}")
eoa = Account.from_key(pk).address
matic_before = float(w3.from_wei(w3.eth.get_balance(eoa), 'ether'))
if matic_before < 0.05:
return result # skip - needs MATIC for gas
positions = get_redeemable(safe)
valid_positions = [p for p in positions if p.get('conditionId')]
for attempt in range(3):
try:
ok, tx_hash, matic_used = batch_redeem(w3, pk, safe, valid_positions)
if ok: break
except Exception as e:
time.sleep((attempt + 1) * 5)
# --- PSEUDO-CODE ---
FUNCTION process_account(web3, account suffix like "_MAY13"):
LOAD the private key and safe address from .env using the suffix
GET the EOA (externally owned account) address
CHECK how much MATIC is available for gas fees
IF less than 0.05 MATIC: SKIP this account (can't afford gas)
CALL Polymarket API to find all redeemable positions for this account
FILTER to keep only positions with valid condition IDs
TRY up to 3 times to batch redeem:
CALL batch_redeem() with all valid positions
IF successful: STOP retrying
IF failed: WAIT (5, 10, or 15 seconds) and try again
CALCULATE actual MATIC spent (before - after balance)