October 10, 2025
By Rian Dolphin
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.
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.
Polymarket supports three signature types. Understanding them matters because the merge transaction depends on which you're using.
Type 0: EOA (Externally Owned Account)
your_wallet → CTF.mergePositions()Type 1: POLY_PROXY (Magic/Email Login)
Type 2: POLY_GNOSIS_SAFE (MetaMask/Browser Wallet)
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.
Before fixing the proxy routing, need to understand what we're actually calling.
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 questionoutcomeSlotCount: 2 for binary marketsindexSet: 0b01 (1) for first outcome, 0b10 (2) for secondcollateralToken: USDC addressparentCollectionId: bytes32(0) for PolymarketThis derivation means YES and NO tokens for a market share the same conditionId. That's how the CTF knows they're complementary.
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.
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.
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.
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.
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.
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:
_msgSender() to identify the caller (your EOA)onlyOwner check at factory levelChanged 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.
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:
onlyOwner restrictionThe 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.
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.
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.
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.
Full workflow:
conditionId to find YES/NO pairsmin(yes_size, no_size)proxy() functionExample 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
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.
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:
execTransaction()More tooling available for Gnosis Safe. But if you're on POLY_PROXY (type 1), you need the factory pattern described above.
Quick aside on why Polymarket uses this architecture.
Problem: Ethereum transaction UX is bad. Users need:
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:
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.
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:
Then run for real.
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.
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
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.