Merging Polymarket positions: A real hassle to get working

Market making on Polymarket leaves you with a problem: offsetting positions lock up capital. Buy YES at 0.48, sell NO at 0.51, you've got 1 YES share and 1 NO share sitting there. Together they're worth exactly 1 USDC, but you can't use that capital for new trades.

The solution: merge them back into USDC. Should be simple—call a contract function, recover collateral. Except Polymarket uses proxy wallets for most users, and routing transactions through proxies turns out to be non-obvious. Took several hours of debugging to figure out the correct contract architecture.

This post walks through the full journey: understanding the Conditional Token Framework, navigating three different wallet architectures, finding the right contract to call, and building a working implementation. Dense but systematic.

The Capital Efficiency Problem

When market making, you end up holding both sides. Example from my positions:

Market: "Will Gemini 3.0 be released by October 15?"
YES position: 22.99695 shares @ $0.06 = $1.38
NO position:  23.00000 shares @ $0.94 = $21.62
Total value: $23.00

That's 23 complete sets (1 YES + 1 NO = 1 USDC). Should be able to merge them, get 23 USDC back, deploy it elsewhere.

The Conditional Token Framework (CTF) supports this. Has mergePositions() function that burns complementary outcome tokens, returns collateral. But calling it directly doesn't work when you're using a Polymarket proxy wallet.

Why? Your tokens aren't held by your Ethereum address. They're held by a smart contract wallet that your private key controls. Different architecture, different transaction routing.

Three Wallet Architectures on Polymarket

Polymarket supports three signature types. Understanding them matters because the merge transaction depends on which you're using.

Type 0: EOA (Externally Owned Account)

  • Standard Ethereum wallet
  • Private key directly controls tokens
  • Direct contract calls: your_wallet → CTF.mergePositions()
  • Simple. Not what most users have.

Type 1: POLY_PROXY (Magic/Email Login)

  • Custom Polymarket proxy wallet
  • Two addresses: signer EOA + proxy contract
  • Tokens held by proxy, controlled by signer
  • This is what I had. Most complex to work with.

Type 2: POLY_GNOSIS_SAFE (MetaMask/Browser Wallet)

  • Gnosis Safe multi-sig (configured as 1-of-1)
  • Similar two-address structure
  • Different contract interface than POLY_PROXY
  • Has existing tooling, better documented

Key insight: When you trade through the CLOB (Central Limit Order Book), the py-clob-client handles proxy complexity for you. For orders, you sign with your EOA, library sets maker=proxy_address and signer=eoa_address, Exchange contract knows how to route.

For arbitrary contract calls like merging? You're on your own.

Understanding the Conditional Token Framework

Before fixing the proxy routing, need to understand what we're actually calling.

Position Tokens as ERC1155s

Polymarket outcomes are ERC1155 tokens. Each market has two token IDs—one for YES, one for NO. Those IDs aren't random. They're derived:

conditionId = hash(oracle, questionId, outcomeSlotCount)
collectionId = hash(parentCollectionId, conditionId, indexSet)
positionId = hash(collateralToken, collectionId)

Where:

  • oracle: UMA adapter contract (resolves markets)
  • questionId: hash of market question
  • outcomeSlotCount: 2 for binary markets
  • indexSet: 0b01 (1) for first outcome, 0b10 (2) for second
  • collateralToken: USDC address
  • parentCollectionId: bytes32(0) for Polymarket

This derivation means YES and NO tokens for a market share the same conditionId. That's how the CTF knows they're complementary.

Split and Merge Operations

CTF supports two operations:

Split: Turn collateral into outcome tokens

CTF.splitPosition(
    collateralToken,     # USDC
    parentCollectionId,  # 0x00...00
    conditionId,         # market identifier
    partition,           # [1, 2] for YES/NO
    amount               # in USDC wei (6 decimals)
)

This locks amount USDC, mints amount of each outcome token.

Merge: Turn outcome tokens back into collateral

CTF.mergePositions(
    collateralToken,
    parentCollectionId,
    conditionId,
    partition,
    amount
)

Burns amount of each outcome token, returns amount USDC.

The merge operation requires you hold at least amount of BOTH tokens. Can't merge 10 YES + 5 NO—you'd merge 5 complete sets, left with 5 YES.

Negative Risk Markets

Quick aside: some markets use a different adapter contract. "Negative risk" markets are multi-outcome winner-take-all events (e.g., "Which company will have the best AI model?").

Key property: 1 NO share in any outcome = 1 YES share in all other outcomes. Allows capital-efficient conversions without trading.

For negative risk markets, call NegRiskAdapter.mergePositions(conditionId, amount) instead of the CTF directly. Same idea, different contract. The positions API includes a negativeRisk boolean so you know which to use.

The Proxy Wallet Problem

Now to actually call these contracts. First attempt: build transaction, sign with private key, send.

# Naive approach - doesn't work
ctf_contract = web3.eth.contract(address=CTF_ADDRESS, abi=CTF_ABI)

tx = ctf_contract.functions.mergePositions(
    USDC_ADDRESS,
    bytes(32),           # parentCollectionId
    condition_id_bytes,
    [1, 2],             # partition
    amount_wei
).build_transaction({
    'from': proxy_address,  # ← the proxy holds the tokens
    'gas': 300000,
    'gasPrice': web3.eth.gas_price,
    'nonce': web3.eth.get_transaction_count(proxy_address),
})

signed_tx = web3.eth.account.sign_transaction(tx, private_key)
tx_hash = web3.eth.send_raw_transaction(signed_tx.rawTransaction)

Error:

TypeError: from field must match key's 0x9811..., but it was 0xf8c8...

Right. The proxy holds the tokens, but I can only sign transactions as the signer EOA. Can't sign as a smart contract.

Need to route the transaction through the proxy.

Finding the Proxy Interface

The proxy wallet is a smart contract. Must have some function that lets the owner (my EOA) tell it to call other contracts.

Checked Polygonscan for my proxy address. Contract isn't verified—shows bytecode but no source. However, the "Created by" field points to the Polymarket Proxy Factory at 0xaB45c5A4B0c941a2F231C04C3f49182e1A254052.

Factory contracts deploy many instances from a template. All proxies share the same implementation. Factory IS verified on Polygonscan, so I could see available functions.

Found this in the Write Contract tab:

function proxy(
    tuple[] calls  // array of ProxyCall structs
) payable returns (bytes[] returnValues)

Where ProxyCall is:

struct ProxyCall {
    uint8 typeCode;      // 1 = CALL, 2 = DELEGATECALL
    address to;          // target contract
    uint256 value;       // ETH to send
    bytes data;          // encoded function call
}

This looked right. Take an encoded contract call, wrap it in a struct, pass to proxy. But description said "passes array of contract calls from GSN relayer through to proxy wallet"—made it sound like it's only for Polymarket's Gas Station Network relayer.

Tried it anyway.

First Attempt: Calling the Proxy Directly

Built the transaction:

# Encode the CTF merge call
ctf_contract = web3.eth.contract(address=CTF_ADDRESS, abi=CTF_ABI)
merge_data = ctf_contract.encode_abi(
    'mergePositions',
    args=[USDC_ADDRESS, bytes(32), condition_id_bytes, [1, 2], amount_wei]
)

# Wrap in ProxyCall struct
proxy_call = (
    1,              # typeCode (CALL)
    CTF_ADDRESS,    # to
    0,              # value
    merge_data      # data
)

# Call proxy's proxy() function
proxy_contract = web3.eth.contract(address=proxy_address, abi=PROXY_ABI)
tx = proxy_contract.functions.proxy(
    [proxy_call]
).build_transaction({
    'from': signer_address,
    'gas': 500000,
    'gasPrice': web3.eth.gas_price,
    'nonce': web3.eth.get_transaction_count(signer_address),
    'value': 0
})

signed_tx = web3.eth.account.sign_transaction(tx, private_key)
tx_hash = web3.eth.send_raw_transaction(signed_tx.rawTransaction)

Transaction went through! But reverted on-chain:

status: 'failed'

Checked the contract source. The proxy() function has an onlyOwner modifier that checks authorization. But the check wasn't straightforward—used GSN's _msgSender() context, not just msg.sender.

Turns out calling the proxy wallet directly requires complex authorization I didn't have set up.

The Factory Pattern Solution

Looked at how Polymarket's UI does merges. Found example code in a GitHub repo for Gnosis Safe merges:

const safeAddress = process.env.BROWSER_ADDRESS;
const safe = new ethers.Contract(safeAddress, safeAbi, wallet);

const txResponse = await signAndExecuteSafeTransaction(
  wallet,
  safe,
  transaction.to,
  transaction.data,
  { gasPrice, gasLimit }
);

They weren't calling the wallet directly. They were using a factory pattern with GSN routing.

Key insight: Call proxy() on the FACTORY contract, not the wallet contract. The factory:

  1. Uses _msgSender() to identify the caller (your EOA)
  2. Looks up which proxy wallet you own
  3. Routes the call to your specific proxy
  4. No onlyOwner check at factory level

Changed the code:

# Use factory address instead of wallet address
PROXY_FACTORY = "0xaB45c5A4B0c941a2F231C04C3f49182e1A254052"

proxy_contract = web3.eth.contract(
    address=PROXY_FACTORY,  # ← changed from proxy_address
    abi=PROXY_ABI
)

tx = proxy_contract.functions.proxy([proxy_call]).build_transaction({
    'from': signer_address,
    'gas': 500000,
    'gasPrice': web3.eth.gas_price,
    'nonce': web3.eth.get_transaction_count(signer_address),
})

This worked. Transaction succeeded, positions merged, USDC recovered.

The Complete Flow

Final architecture:

[Your EOA]
    → signs transaction
    → calls ProxyFactory.proxy([proxy_call])
        → Factory identifies you via _msgSender()
        → Factory routes to YourProxyWallet.invoke()
            → Proxy calls CTF.mergePositions(...)
                → CTF burns YES/NO tokens from Proxy
                → CTF sends USDC to Proxy

Key points:

  • Signer pays gas (needs POL in EOA)
  • Proxy holds tokens and receives USDC
  • Factory handles routing automatically
  • Works because factory doesn't have onlyOwner restriction

Implementation Details

Gas Requirements

The signer EOA needs POL (Polygon's native token) for gas. Transaction costs ~0.002 POL ($0.0004 at current prices). Keep some POL in the signer for multiple operations.

The proxy itself doesn't need POL, gas is paid by whoever signs the transaction.

Amount Precision

USDC has 6 decimals (POL uses 18). When merging 10 positions:

amount_wei = int(10 * 1e6)  # 10000000

Position sizes from the API are floats. If you have 22.99695 YES and 23.0 NO, you can merge min(22.99695, 23.0) = 22.99695 sets. Worth nothing that I placed an order for 23 but when I checked my positions I only had 22.99...

Convert to wei:

mergeable = min(yes_size, no_size)
amount_wei = int(mergeable * 1e6)  # 22996950

After merge, you're left with 0.00305 NO shares. Fractional shares accumulate from fees and rounding. Merge when they build up, or accept the dust.

The Proxy ABI Issue

Getting the ABI right was tricky. Initial attempt included a payableAmount parameter (blaming Claude for that):

# Wrong ABI
PROXY_ABI = [{
    "inputs": [
        {"name": "payableAmount", "type": "uint256"},  # ← doesn't exist
        {"name": "calls", "type": "tuple[]"}
    ],
    "name": "proxy",
    ...
}]

Correct ABI has no payableAmount, just the calls array:

# Correct ABI
PROXY_ABI = [{
    "inputs": [
        {
            "components": [
                {"name": "typeCode", "type": "uint8"},
                {"name": "to", "type": "address"},
                {"name": "value", "type": "uint256"},
                {"name": "data", "type": "bytes"}
            ],
            "name": "calls",
            "type": "tuple[]"
        }
    ],
    "name": "proxy",
    "outputs": [{"name": "returnValues", "type": "bytes[]"}],
    "payable": True,
    "stateMutability": "payable",
    "type": "function"
}]

The function is payable (can receive ETH), but you don't pass a separate amount parameter. If you want to send ETH, set it in the transaction's value field.

Handling Negative Risk

Check the negativeRisk field from the positions API. Route to different contract:

if is_neg_risk:
    target = NEG_RISK_ADAPTER  # 0xd91E80cF...
    merge_data = neg_risk_contract.encode_abi(
        'mergePositions',
        args=[condition_id_bytes, amount_wei]
    )
else:
    target = CTF_ADDRESS  # 0x4D97DCd...
    merge_data = ctf_contract.encode_abi(
        'mergePositions',
        args=[USDC_ADDRESS, bytes(32), condition_id_bytes, [1, 2], amount_wei]
    )

proxy_call = (1, target, 0, merge_data)

NegRisk adapter takes fewer parameters—no collateral token, parent collection, or partition. Just condition ID and amount.

Building the Cleanup Function

Full workflow:

  1. Query positions from Polymarket API
  2. Group by conditionId to find YES/NO pairs
  3. Calculate mergeable amount: min(yes_size, no_size)
  4. For each pair:
    • Encode the merge call (CTF or NegRisk)
    • Wrap in ProxyCall struct
    • Call factory's proxy() function
    • Wait for confirmation
  5. Verify USDC recovered to proxy wallet

Example identification logic:

def identify_mergeable_positions(positions_df):
    # Filter to mergeable positions
    mergeable = positions_df.filter(pl.col("mergeable") == True)

    # Group by condition ID
    paired = (
        mergeable
        .group_by("conditionId")
        .agg([
            pl.col("size").alias("sizes"),
            pl.col("outcome").alias("outcomes"),
        ])
        .filter(pl.col("outcomes").list.len() == 2)  # both YES and NO
    )

    # Calculate mergeable amount
    result = paired.with_columns([
        pl.col("sizes").list.min().alias("mergeable_amount")
    ])

    return result

When to Run

Market making leaves you with offsetting positions over time. Three strategies:

After every pair of trades: Immediate capital recovery, max gas costs. Makes sense if trading large size where opportunity cost of locked capital exceeds gas.

End of day: Balance between capital efficiency and gas. If trading frequently, positions accumulate. Single merge operation cheaper than continuous merging.

When capital constrained: Only merge when you need liquidity for new positions. Lazy approach, works if you have excess capital.

Comparison to Gnosis Safe

If using signature type 2 (browser wallet with Gnosis Safe), the flow is different but conceptually similar. Gnosis Safe has its own transaction routing:

// Gnosis Safe approach
const safe = new ethers.Contract(safeAddress, safeAbi, wallet);
await signAndExecuteSafeTransaction(
  wallet,
  safe,
  CTF_ADDRESS,        // target
  mergeCallData,      // encoded merge
  { gasPrice, gasLimit }
);

The signAndExecuteSafeTransaction helper handles:

  • Building the Safe transaction structure
  • Getting required signatures (just 1 for 1-of-1 config)
  • Calling Safe's execTransaction()
  • Relaying if using GSN

More tooling available for Gnosis Safe. But if you're on POLY_PROXY (type 1), you need the factory pattern described above.

Theory: Why Proxy Wallets?

Quick aside on why Polymarket uses this architecture.

Problem: Ethereum transaction UX is bad. Users need:

  • ETH/POL for gas
  • Understanding of gas prices
  • Wallet software that handles signing
  • Patience for block confirmations

Solution: Smart contract wallets + meta-transactions. User signs messages (free, instant), relayer submits transactions and pays gas. Polymarket eats the gas cost, provides better UX.

The POLY_PROXY architecture:

  • User creates account with email (Magic.link)
  • Generates EOA private key client-side
  • Deploys proxy wallet contract on first action
  • All subsequent actions go through proxy
  • Polymarket's GSN relayer pays gas, user signs

For trading, this is seamless—CLOB client abstracts it. For custom operations like merging, you hit the proxy routing complexity.

Alternative: Move everything to a normal EOA, trade directly. Simpler programmatically. But then you're paying gas on every trade, and you lose the Polymarket UI integration. Tradeoff.

Practical Considerations

Testing

Dry-run mode before executing:

def merge_positions(condition_id, amount, is_neg_risk, dry_run=False):
    # ... build transaction ...

    if dry_run:
        return {
            'condition_id': condition_id,
            'amount': amount,
            'target': target_address,
            'encoded_data_length': len(merge_data),
            'estimated_gas': 500000,
            'dry_run': True
        }

    # ... execute transaction ...

Verify:

  • Correct condition ID
  • Reasonable amount
  • Right contract (CTF vs NegRisk)
  • Encoded data looks right

Then run for real.

Error Handling

Common failures:

Insufficient balance: Trying to merge more than you hold. Check min(yes_size, no_size) first.

Gas too low: Merge transactions use ~300-400k gas. Set limit to 500k to be safe.

Wrong network: Easy to mix up Mumbai testnet vs Polygon mainnet addresses. Double-check contract addresses.

Nonce conflicts: If submitting multiple transactions quickly, track nonces manually:

nonce = web3.eth.get_transaction_count(signer_address)
# use nonce, increment for next tx

Factory routing fails: Make sure calling factory address (0xaB45...), not your proxy address.

Monitoring

After merge, verify USDC arrived:

usdc_contract = web3.eth.contract(address=USDC_ADDRESS, abi=ERC20_ABI)
balance_before = usdc_contract.functions.balanceOf(proxy_address).call()

# execute merge

balance_after = usdc_contract.functions.balanceOf(proxy_address).call()
recovered = (balance_after - balance_before) / 1e6

assert recovered == expected_amount

Also check position balances decreased:

ctf_contract = web3.eth.contract(address=CTF_ADDRESS, abi=CTF_ABI)
yes_balance = ctf_contract.functions.balanceOf(proxy_address, yes_token_id).call()
no_balance = ctf_contract.functions.balanceOf(proxy_address, no_token_id).call()

# should both have decreased by merged amount

Conclusion

Anyway, that was a long road to finally get merging working programatically. Also, I got ripped off by Kraken on actually transferring POL to the signer wallet. They charged a fixed 4 POL fee.

Related Posts

Polymarket Balance/Allowance Error: A Fractional Shares Problem?

October 07, 2025

prediction_markets
polymarket
prediction markets
Read More

Stock Embeddings - Learning Distributed Representations for Financial Assets

April 08, 2025

research
machine-learning
finance
Read More