← Back to All Tutorials

Tutorial 09: Polymarket Redemption Bot Intermediate

Table of Contents
  1. What Does This Bot Do?
  2. Key Concepts
  3. Code Walkthrough
  4. How Batch Redemption Works
  5. Dependencies
  6. Glossary

1. What Does This Bot Do?

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.

Simple Analogy: Imagine you have winning lottery tickets at 7 different stores. Instead of driving to each store individually (7 trips = 7 gas costs), this bot is like a delivery truck that picks up all your winnings in one trip (1 batch = 1 gas cost). It handles both regular lottery tickets and special "multi-prize" tickets automatically.

2. Key Concepts

Redeeming on Polymarket

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:

  1. Proves you hold winning shares
  2. Burns (destroys) those shares
  3. Sends USDC to your wallet

Each redemption requires a transaction on the Polygon blockchain, which costs MATIC (gas).

Regular vs Neg-Risk Markets

Market TypeContractHow to Redeem
RegularCTF (Conditional Token Framework)CTF.redeemPositions()
Neg-RiskNegRiskAdapterNegRiskAdapter.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.

Gnosis Safe (Multi-Sig Wallet)

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.

MultiSend (Batching)

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.

3. Code Walkthrough

1 sanitize_error() - Security First

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.

2 get_redeemable() - Find Winning Positions

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).

3 encode_redeem_regular() / encode_redeem_neg_risk() - Build Transaction Data

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.

4 batch_redeem() - The Core Function

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.

4. How Batch Redemption Works

MultiSend Format:

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.

Savings Example:

Method10 RedemptionsGas Cost
Individual transactions10 separate txs~0.008 MATIC total
Batch (MultiSend)1 transaction~0.004 MATIC total
Savings~50% less gas

5. Dependencies

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
Security: This bot handles private keys for multiple accounts. Never share your keys, never commit your .env file, and always double-check the addresses before running.

6. Glossary

TermMeaning
RedeemClaiming winnings from a resolved market by burning winning shares
GasThe fee paid to process a blockchain transaction (paid in MATIC on Polygon)
MultiSendA contract that executes multiple operations in a single transaction
Gnosis SafeA multi-signature wallet contract for secure fund management
EOAExternally Owned Account - a regular wallet controlled by a private key
Neg-RiskMulti-outcome markets on Polymarket using a special contract adapter
CTFConditional Token Framework - Polymarket's core token contract
ABI EncodingFormatting function calls as raw bytes for blockchain transactions
DELEGATECALLA special call type that runs another contract's code in the caller's context

7. Full Code: Python to Pseudo-Code Translation

get_redeemable() - Find Winnable Positions

# --- 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

encode_redeem_regular() - Build Smart Contract Call for Normal Markets

# --- 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)

batch_redeem() - Execute the Batch Transaction

# --- 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

process_account() - Handle One Account's Redemptions

# --- 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)