The Dual-Approval Wall
Trading on Polymarket requires two separate on-chain approvals before the first order works — ERC-20 approve() for USDC.e collateral and CTF setApprovalForAll() for outcome tokens. Every first-time integrator hits this wall. Here is the failure mode for each missing approval, why two token standards are involved, and the verification flow that catches both before any order fires.
Every Polymarket integrator hits this wall on their first attempt. The wallet is funded with USDC.e. The bot is configured. The first order fires. And it is rejected — or worse, buys go through but the first sell silently fails.
The wall has two layers: a missing ERC-20 collateral approval and a missing CTF operator approval. They are separate transactions, targeting different contracts, governed by different token standards. Both must be set before any trade completes cleanly.
Why Two Token Standards
Polymarket uses two distinct on-chain primitives simultaneously.
Collateral is ERC-20. USDC.e — the bridged PoS version at 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 — is a standard ERC-20 token. ERC-20 uses a per-spender allowance model: approve(spender, amount) grants one specific contract permission to pull up to amount tokens from your wallet. Without this, no external contract can move your USDC.e regardless of how much you hold.
Outcome tokens are ERC-1155. When you buy a position on Polymarket, you receive YES or NO shares for that specific market condition. These are governed by the Conditional Token Framework (CTF), which implements ERC-1155 — a multi-token standard. ERC-1155 uses an operator model: setApprovalForAll(operator, bool) grants a single operator permission to move all of your tokens across all token IDs in that contract. This is the CTF contract's approval, targeting the exchange as operator.
The exchange contract sits in the middle and needs both permissions: the ERC-20 allowance to pull collateral when filling a buy order, and the CTF operator permission to transfer outcome tokens when you sell.
Failure Mode: Missing ERC-20 Approval
When approve() has not been called on the USDC.e contract, the exchange cannot pull collateral. The CLOB rejects every order at submission. The error varies by client library — it might appear as insufficient allowance, ERC20: transfer amount exceeds allowance, or a generic 400 from the CLOB API — but the root cause is always the same.
Your wallet balance is real. The order logic is correct. The signature is valid. The exchange simply has no permission to spend your collateral.
Symptom: every order fails at submission, regardless of side (buy or sell).
Verification:
allowance = usdc_e_contract.functions.allowance(wallet, exchange_address).call()
assert allowance > 0, "ERC-20 approval missing — run approve() first"
Fix:
usdc_e_contract.functions.approve(exchange_address, 2**256 - 1).build_transaction(...)
Using MAX_UINT256 (2**256 - 1) is the standard unlimited-approval pattern. It avoids the need to re-approve as your balance decreases below the previous allowance amount.
Failure Mode: Missing CTF Approval
When setApprovalForAll() has not been called on the CTF contract, the exchange cannot move your outcome token positions. This failure is subtler than the ERC-20 case.
Buys may succeed. When you buy YES shares, the exchange is transferring tokens to your wallet — no operator permission required for the recipient. Your order appears to fill correctly.
Sells fail. When you try to sell your YES shares back, the exchange needs to transfer them from your wallet. This requires operator permission. Without it, the transaction reverts with ERC1155: caller is not token owner nor approved.
Redemptions are different. Redeeming a winning position after resolution does not go through the exchange when you hold tokens at your own EOA (the type-0 setup this track teaches): you call redeemPositions on the CTF yourself, and the contract burns your own tokens — no operator approval involved. The CTF approval gates sells, not direct redemptions. (One caveat: positions in negative-risk multi-outcome markets redeem through the NegRiskAdapter, which does need its own approvals — covered below.)
Symptom: buys succeed; sells fail.
Verification:
approved = ctf_contract.functions.isApprovedForAll(wallet, exchange_address).call()
assert approved, "CTF approval missing — run setApprovalForAll() first"
Fix:
ctf_contract.functions.setApprovalForAll(exchange_address, True).build_transaction(...)
The Verification Checklist
Run both checks before placing any order. These are read-only calls — no gas, no transactions.
One subtlety first: there is more than one spender. Binary markets trade through the CTF Exchange. Multi-outcome political markets — the bulk of what this track's examples target — are negative-risk markets that route through a separate Neg Risk CTF Exchange and its Neg Risk Adapter. Each of those contracts independently needs the ERC-20 allowance and the CTF operator approval. A bot that approves only the CTF Exchange will pass its own startup check and still have its first neg-risk order or sell rejected.
USDC_E_ADDRESS = "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
CTF_ADDRESS = "0x4D97DCd97eC945f40cF65F87097ACe5EA0476045"
# Every contract that can pull your collateral or move your outcome tokens
# needs BOTH approvals. Verify against the current addresses in the
# Polymarket docs / py-clob-client constants before deploying.
SPENDERS = {
"ctf_exchange": "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E",
"neg_risk_exchange": "0xC5d563A36AE78145C45a50134d48A1215220f80a",
"neg_risk_adapter": "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296",
}
def check_approvals(wallet: str, w3) -> dict:
usdc_e = w3.eth.contract(address=USDC_E_ADDRESS, abi=ERC20_ABI)
ctf = w3.eth.contract(address=CTF_ADDRESS, abi=ERC1155_ABI)
status: dict = {"ready_to_trade": True}
for name, spender in SPENDERS.items():
allowance = usdc_e.functions.allowance(wallet, spender).call()
approved = ctf.functions.isApprovedForAll(wallet, spender).call()
status[name] = {"erc20_ok": allowance > 0, "ctf_ok": approved}
status["ready_to_trade"] = (
status["ready_to_trade"] and allowance > 0 and approved
)
return status
If ready_to_trade is False, run whichever approvals are missing before proceeding.
Allowance Scoping and Revocation Hygiene
MAX_UINT256 is standard. Polymarket and most CLOB integrations use unlimited approvals for the collateral token. This is common practice for exchange integrations — you approve once and never think about it again. The risk is theoretical: if the exchange contract is compromised, an attacker could drain your approved balance. In practice, Polymarket's contracts are immutable and audited.
Per-amount approvals are possible but not recommended for bot use. If you approve 100 USDC.e, you need to re-approve every time your cumulative order volume approaches that limit. This adds operational complexity with no practical security benefit for a bot holding a defined trading budget.
Revocation. To revoke the ERC-20 allowance, call approve(exchange_address, 0). To revoke the CTF approval, call setApprovalForAll(exchange_address, False). Both are on-chain transactions. For bots that have stopped trading, revoking approvals is a hygiene step worth taking — it eliminates the theoretical attack surface entirely.
The Idempotent Setup Pattern
The production-safe approval setup is idempotent: check the current state, skip the transaction if already approved, transact only if needed.
def ensure_approvals(wallet: str, private_key: str, w3) -> None:
status = check_approvals(wallet, w3)
for name, spender in SPENDERS.items():
if not status[name]["erc20_ok"]:
tx_hash = send_erc20_approval(spender, wallet, private_key, w3)
logger.info(f"ERC-20 approval set spender={name} tx={tx_hash}")
if not status[name]["ctf_ok"]:
tx_hash = send_ctf_approval(spender, wallet, private_key, w3)
logger.info(f"CTF approval set spender={name} tx={tx_hash}")
# Re-verify
final = check_approvals(wallet, w3)
if not final["ready_to_trade"]:
raise RuntimeError(f"Approvals incomplete after setup: {final}")
logger.info("dual_approval_verified wallet=%s", wallet)
Call ensure_approvals once at bot startup, after the wallet derivation and balance checks from the earlier lessons in this track. If every approval is already set, the function exits immediately with a handful of read calls and no gas cost. If any is missing, it sets that approval, then re-verifies to confirm the transaction landed.
The Two-Approval Checklist
Before placing your first order on a new wallet:
- Derive the EOA from the configured private key (the “Balance Guards and Derived-Address Validation” lesson)
- Verify on-chain USDC.e balance > 0 and matches the CLOB balance (the “Balance Guards and Derived-Address Validation” lesson)
allowance(wallet, spender) > 0for each spender (CTF Exchange, Neg Risk Exchange, Neg Risk Adapter) — ERC-20 approval for USDC.e collateralisApprovedForAll(wallet, spender) == truefor each spender — CTF approval for outcome tokens- All four checks pass → submit the first order
Steps 1–2 from the “Balance Guards and Derived-Address Validation” lesson. Steps 3–4 from this lesson. All four together make up the complete startup guard that eliminates the most common silent failure modes in Polymarket bots.