24 KiB
Polymarket NegRisk Reference (Polygon mainnet)
Single-page reference for arbitraging categorical Polymarket events where
Σ(best-ask of every outcome) < $1. Covers the contracts, ABI, lifecycle, Gamma
API detection, ID derivation, a runnable web3.py snippet, and gotchas.
All sources cited inline. Last verified: April 2026.
1. Concept
Vanilla Polymarket markets use Gnosis's Conditional Token Framework (CTF):
each binary market mints a YES and a NO ERC-1155 token, fully collateralized 1:1
by USDC.e. Splitting 1 USDC.e gives 1 YES + 1 NO; merging the pair returns
1 USDC.e; after resolution the winning side redeems for 1 USDC.e each.
A categorical event ("Who wins the 2028 US Election?") is modeled as N
independent binary markets — one per candidate. Without negRisk these markets
are unconnected, which means a holder of NO on every candidate is locked up
even though, by construction, exactly one of them must resolve YES. NegRisk
fixes this: the NegRiskAdapter wraps the underlying CTF and adds a
convertPositions operation: 1 NO share in market i of an event can be
atomically converted into 1 YES share in every other market of that event.
That makes a complete set of YES tokens (one per outcome) economically
equivalent to $1 USDC.e and lets capital be freed early instead of waiting for
oracle resolution. This is the property the arb strategy exploits — when the
best-ask sum of every outcome's YES token is below $1, you can buy a complete
set, redeem (or convert+redeem), and lock in the spread.
(NegRisk overview,
neg-risk-ctf-adapter README,
ChainSecurity audit, Apr 2024)
2. Contract addresses (Polygon mainnet, chainId 137)
Source: Polymarket Contract Addresses, cross-checked on PolygonScan.
| Contract | Address | PolygonScan |
|---|---|---|
| NegRiskAdapter | 0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296 |
link |
| NegRiskCtfExchange | 0xC5d563A36AE78145C45a50134d48A1215220f80a |
link |
| NegRiskFeeModule | 0x78769D50Be1763ed1CA0D5E878D93f05aabff29e |
link |
| CTFExchange (vanilla) | 0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E |
link |
| ConditionalTokens (CTF) | 0x4D97DCd97eC945f40cF65F87097ACe5EA0476045 |
link |
| USDC.e (collateral) | 0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174 |
link |
| UmaCtfAdapter (oracle) | 0x6A9D222616C90FcA5754cd1333cFD9b7fb6a4F74 |
link |
| UMA Optimistic Oracle | 0xCB1822859cEF82Cd2Eb4E6276C7916e692995130 |
link |
The collateral is USDC.e (the bridged PoS USDC), not native Circle-issued USDC (
0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359). Confusing these two will silently break allowance checks. See Gotchas §8.
3. Key ABI signatures
The NegRiskAdapter exposes both vanilla CTF-shaped overloads (so it can act as
a drop-in IConditionalTokens proxy) and negRisk-specific entrypoints. Source:
NegRiskAdapter.sol,
docs/NegRiskAdapter.md.
3.1 Position management (call these on the NegRiskAdapter)
// Mint a complete set: deposits `_amount` USDC.e, mints `_amount` of each YES & NO.
function splitPosition(bytes32 _conditionId, uint256 _amount) external;
// Burn a complete set (YES + NO of every outcome of the condition) for USDC.e.
function mergePositions(bytes32 _conditionId, uint256 _amount) external;
// After UMA resolution, redeem held outcome tokens for USDC.e payout.
// _amounts[i] is the amount of outcome-i token to burn.
function redeemPositions(bytes32 _conditionId, uint256[] calldata _amounts) external;
// negRisk-specific: convert NO shares in markets selected by `_indexSet`
// (a bitmask over the marketId's questions) into YES shares of the rest + collateral.
function convertPositions(bytes32 _marketId, uint256 _indexSet, uint256 _amount) external;
There are also legacy 5-arg overloads kept for IConditionalTokens API parity
(unused by clients in practice):
function splitPosition(address _collateralToken, bytes32, bytes32 _conditionId,
uint256[] calldata, uint256 _amount) external;
function mergePositions(address _collateralToken, bytes32, bytes32 _conditionId,
uint256[] calldata, uint256 _amount) external;
3.2 ID lookups (view)
function getConditionId(bytes32 _questionId) external view returns (bytes32);
function getPositionId(bytes32 _questionId, bool _outcome) external view returns (uint256);
function balanceOf(address _owner, uint256 _id) external view returns (uint256);
function balanceOfBatch(address[] memory _owners, uint256[] memory _ids)
external view returns (uint256[] memory);
3.3 Admin / oracle (you will not call these, but useful for tracing)
function prepareMarket(uint256 _feeBips, bytes calldata _metadata) external returns (bytes32);
function prepareQuestion(bytes32 _marketId, bytes calldata _metadata) external returns (bytes32);
function reportOutcome(bytes32 _questionId, bool _outcome) external; // onlyOperator
3.4 Required ERC-20 / ERC-1155 approvals
Before any of the above, set:
// USDC.e:
IERC20(USDC_E).approve(NegRiskAdapter, type(uint256).max);
// ERC-1155 outcome tokens (for merge / redeem / convert):
IConditionalTokens(CTF).setApprovalForAll(NegRiskAdapter, true);
(Source: NegRiskAdapter.sol)
4. End-to-end arb lifecycle
For a categorical event with N outcomes where Σ best_ask_i < 1:
-
Approvals (one-time per wallet)
USDC.e.approve(NegRiskAdapter, 2^256-1)ConditionalTokens.setApprovalForAll(NegRiskAdapter, true)- Approvals required for the NegRiskCtfExchange (
0xC5d5...80a) for trading:USDC.e.approve(exchange, ...)andConditionalTokens.setApprovalForAll(exchange, true).
-
Buy a complete set via the Exchange
- Use the CLOB (
py-clob-client, setneg_risk=Trueon the order options) to lift the best ask of each of the N outcome tokens forsizeshares. Total USDC.e spent ≈size * Σ best_ask_i, which is <size * $1. - Equivalent: hit each
clobTokenIds[YES]from the Gammamarkets[]array.
- Use the CLOB (
-
Redeem on resolution OR free capital early
- Patient path: wait for UMA to resolve the event and call
NegRiskAdapter.redeemPositions(conditionId_winner, [size, 0])on the winning binary market. Payout =size * 1 USDC.e. Profit =size * (1 − Σ best_ask_i)minus gas and fees. - Capital-recycling path (the negRisk superpower): once you hold one
YES of every outcome of the event, that bundle is economically
$1per unit. Rather than redeem on each binary, you canconvertPositionsto consolidate, or simply burn the bundle: per the adapter, holding the full YES set is interchangeable with USDC.e, so amergePositionson each conditionId (each binary has its own NO if you also hold it, or useconvert) returns USDC.e instantly without waiting for the oracle. Practically: most arb bots redeem after resolution because acquiring a full NO+YES pair on every binary defeats the point — you bought only the YES legs for the discount.
- Patient path: wait for UMA to resolve the event and call
-
USDC.e arrives in your wallet. Fees: NegRisk markets pay a small protocol fee on conversion (defined by
_feeBipsatprepareMarkettime, paid to the Vault); redeem itself has no Polymarket fee.
5. Detecting negRisk markets via the Gamma API
Endpoint: https://gamma-api.polymarket.com/events?...
The two flags that matter on each event JSON object:
| JSON field | Type | Meaning |
|---|---|---|
negRisk |
bool | true → categorical event; outcomes are tied via the NegRiskAdapter. |
negRiskMarketID |
hex string (bytes32) |
The shared marketId that links every binary in this event. Same value also appears on each child markets[i].negRiskMarketID. |
enableNegRisk |
bool | Set on a market when it can be added later as a new outcome (placeholder-capable). |
negRiskAugmented |
bool | Indicates the event has been augmented with such placeholder markets. |
The relevant fields inside each markets[i] element:
| JSON field | Use |
|---|---|
conditionId (bytes32) |
Pass to redeemPositions / splitPosition. |
questionID (bytes32) |
Source of conditionId via getConditionId(questionID). |
clobTokenIds |
[YES_tokenId, NO_tokenId] as decimal strings. These are the ERC-1155 ids you reference to the CLOB order book. |
outcomePrices |
["yes", "no"] last-trade probabilities. Use book REST/WS for live best ask. |
Sample (trimmed) — 2026 FIFA World Cup Winner event:
{
"id": "12345",
"negRisk": true,
"negRiskMarketID": "0xb5c32a9acd39848acad4913ac4cd49c5de2afcc9d23a8a7ba2419375fab87400",
"markets": [
{
"questionID": "0x...",
"conditionId": "0x7976b8dbacf9077eb1453a62bcefd6ab2df199acd28aad276ff0d920d6992892",
"clobTokenIds": ["4394372887385518214471608448209527405727552777602031099972143344338178308080",
"112680630004798425069810935278212000865453267506345451433803052322987302357330"],
"outcomePrices": ["0.1715","0.8285"],
"negRiskMarketID": "0xb5c32a9acd39848acad4913ac4cd49c5de2afcc9d23a8a7ba2419375fab87400"
},
...
]
}
Sample query to surface candidates:
import requests
events = requests.get(
"https://gamma-api.polymarket.com/events",
params={"closed": "false", "limit": 200, "order": "volume24hr",
"ascending": "false"},
timeout=15,
).json()
neg_risk_events = [e for e in events if e.get("negRisk")]
for e in neg_risk_events:
yes_asks = [float(m["outcomePrices"][0]) for m in e["markets"]]
if sum(yes_asks) < 0.99: # candidate; verify against live book
print(e["title"], sum(yes_asks))
(Source: live Gamma API response; field list cross-checked against
Polymarket/agents/agents/polymarket/gamma.py
and docs.polymarket.com/developers/neg-risk/overview.)
6. How tokenId and conditionId are derived
NegRisk markets reuse the underlying CTF derivation rules but plug a
WrappedCollateral ERC-20 in place of raw USDC.e. That changes which
collateral address goes into positionId — the one number that frequently
trips up new integrators.
6.1 Vanilla CTF (used by non-negRisk binary markets)
conditionId = keccak256( oracle ‖ questionId ‖ outcomeSlotCount )
collectionId = EC point-add of (parentCollectionId, hashToCurve(conditionId ‖ indexSet))
positionId = uint256( keccak256( collateralToken ‖ collectionId ) )
For a vanilla binary market: oracle = UmaCtfAdapter, outcomeSlotCount = 2,
collateralToken = USDC.e, indexSet = 1 for YES and 2 for NO.
(Source: CTHelpers.sol)
6.2 NegRisk markets (the difference)
For each outcome of a categorical event, NegRisk creates an independent binary CTF condition, but with two changes:
- The CTF
oraclefield is set to the NegRiskAdapter address (0xd91E…5296) — not UmaCtfAdapter. The NegRiskAdapter is itself the thing that callsreportPayoutsupstream. - The
collateralTokenbaked intopositionIdis the WrappedCollateral ERC-20 (deployed by the adapter), not USDC.e. The adapter holds USDC.e and mints/burns wrapper tokens 1:1 against it.
Practically:
marketId(the negRisk-level grouping) =keccak256(operator ‖ feeBips ‖ metadata ‖ nonce)— assigned atprepareMarkettime and is whatnegRiskMarketIDin Gamma exposes.questionIdfor the i-th binary in the event =keccak256(marketId ‖ i)(the index byte is the_questionIndex), which keeps all questions of a categorical event derivable from the single marketId.conditionId = NegRiskAdapter.getConditionId(questionId)=keccak256(NegRiskAdapter ‖ questionId ‖ 2).positionId(YES) = NegRiskAdapter.getPositionId(questionId, true)positionId(NO) = NegRiskAdapter.getPositionId(questionId, false)— these match the decimalclobTokenIdsreturned by the Gamma API.
In practice you do not recompute these. Pull
conditionIdandclobTokenIdsstraight from Gamma; only callgetPositionId/getConditionIdif you want to verify against on-chain truth.
(Source: NegRiskAdapter.sol,
MarketStateLib,
ChainSecurity audit §2.1)
7. End-to-end Python (web3.py) — approve + simulate redeem
Self-contained, structurally complete. Uses placeholder 0x... for the
private key only. The redeem call is built but NOT broadcast — call() runs
it as an eth_call simulation.
"""
Polymarket NegRisk arb — approval + redeem simulation on Polygon.
Requires: web3>=6.20, requests
"""
import os
import requests
from web3 import Web3
from web3.middleware import ExtraDataToPOAMiddleware # PoA chain (Polygon)
# ---- 1. Connect ----------------------------------------------------------
RPC_URL = os.getenv("POLYGON_RPC", "https://polygon-rpc.com")
w3 = Web3(Web3.HTTPProvider(RPC_URL))
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
assert w3.is_connected(), "RPC down"
# ---- 2. Addresses (Polygon mainnet) --------------------------------------
NEG_RISK_ADAPTER = Web3.to_checksum_address("0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296")
NEG_RISK_EXCHANGE = Web3.to_checksum_address("0xC5d563A36AE78145C45a50134d48A1215220f80a")
CTF = Web3.to_checksum_address("0x4D97DCd97eC945f40cF65F87097ACe5EA0476045")
USDC_E = Web3.to_checksum_address("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")
# ---- 3. Wallet (placeholder) --------------------------------------------
PRIVATE_KEY = os.getenv("PK", "0x" + "11" * 32) # placeholder
acct = w3.eth.account.from_key(PRIVATE_KEY)
ME = acct.address
# ---- 4. Minimal ABIs -----------------------------------------------------
ERC20_ABI = [
{"name":"approve","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"spender","type":"address"},{"name":"amount","type":"uint256"}],
"outputs":[{"type":"bool"}]},
{"name":"allowance","type":"function","stateMutability":"view",
"inputs":[{"name":"o","type":"address"},{"name":"s","type":"address"}],
"outputs":[{"type":"uint256"}]},
]
CTF_ABI = [
{"name":"setApprovalForAll","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"operator","type":"address"},{"name":"approved","type":"bool"}],
"outputs":[]},
]
NEG_RISK_ADAPTER_ABI = [
{"name":"splitPosition","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"_conditionId","type":"bytes32"},
{"name":"_amount","type":"uint256"}], "outputs":[]},
{"name":"mergePositions","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"_conditionId","type":"bytes32"},
{"name":"_amount","type":"uint256"}], "outputs":[]},
{"name":"redeemPositions","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"_conditionId","type":"bytes32"},
{"name":"_amounts","type":"uint256[]"}], "outputs":[]},
{"name":"convertPositions","type":"function","stateMutability":"nonpayable",
"inputs":[{"name":"_marketId","type":"bytes32"},
{"name":"_indexSet","type":"uint256"},
{"name":"_amount","type":"uint256"}], "outputs":[]},
{"name":"getConditionId","type":"function","stateMutability":"view",
"inputs":[{"name":"_questionId","type":"bytes32"}],
"outputs":[{"type":"bytes32"}]},
{"name":"getPositionId","type":"function","stateMutability":"view",
"inputs":[{"name":"_questionId","type":"bytes32"},
{"name":"_outcome","type":"bool"}],
"outputs":[{"type":"uint256"}]},
]
usdc = w3.eth.contract(address=USDC_E, abi=ERC20_ABI)
ctf = w3.eth.contract(address=CTF, abi=CTF_ABI)
adapter = w3.eth.contract(address=NEG_RISK_ADAPTER, abi=NEG_RISK_ADAPTER_ABI)
# ---- 5. Approvals (idempotent) -------------------------------------------
MAX = 2**256 - 1
def ensure_approvals():
if usdc.functions.allowance(ME, NEG_RISK_ADAPTER).call() < 10**18:
tx = usdc.functions.approve(NEG_RISK_ADAPTER, MAX).build_transaction({
"from": ME, "nonce": w3.eth.get_transaction_count(ME),
"maxFeePerGas": w3.to_wei(100, "gwei"),
"maxPriorityFeePerGas": w3.to_wei(30, "gwei"),
"chainId": 137,
})
# signed = acct.sign_transaction(tx); w3.eth.send_raw_transaction(signed.raw_transaction)
print("[would broadcast] USDC.e.approve(adapter)")
# also need 1155 approval for merge/redeem/convert legs
print("[would broadcast] CTF.setApprovalForAll(adapter, true)")
ensure_approvals()
# ---- 6. Pull a candidate event from Gamma --------------------------------
events = requests.get(
"https://gamma-api.polymarket.com/events",
params={"closed":"false","limit":50,"order":"volume24hr","ascending":"false"},
timeout=15,
).json()
neg = next(e for e in events if e.get("negRisk") and e.get("markets"))
mkt = neg["markets"][0]
condition_id_hex = mkt["conditionId"] # 0x...
print(f"event: {neg['title']!r} conditionId: {condition_id_hex}")
# ---- 7. Simulate redeem on the YES leg of one binary ---------------------
# amounts MUST line up with outcome slot count (2 for binary): [yes_qty, no_qty]
SIZE = 1_000_000 # 1.0 USDC.e (6 dp); placeholder until balances are real
amounts = [SIZE, 0]
redeem_call = adapter.functions.redeemPositions(
Web3.to_bytes(hexstr=condition_id_hex),
amounts,
)
# eth_call simulation (no broadcast). Will revert if the market is unresolved
# or if you don't actually hold the tokens — both are expected for a dry run.
try:
sim = redeem_call.call({"from": ME})
print("simulated redeemPositions OK; return:", sim)
except Exception as exc:
print("simulated redeemPositions reverted (expected for dry run):", exc)
# Gas estimate for a real broadcast:
try:
gas = redeem_call.estimate_gas({"from": ME})
print("gas estimate:", gas)
except Exception as exc:
print("estimate_gas reverted (likely unresolved or no balance):", exc)
8. Gotchas
- USDC.e ≠ native USDC. Polymarket exclusively uses bridged USDC.e
0x2791…84174. Approving native Circle USDC0x3c499…3359will silently fail every order placement and adapter call. Confirm balance withusdc.functions.symbol().call() == "USDC"and address match. - Two distinct allowances. You must
approve(USDC.e → NegRiskAdapter)andsetApprovalForAll(CTF → NegRiskAdapter, true). The second is needed formergePositions,redeemPositions, andconvertPositionsbecause the adapter pulls your ERC-1155 outcome tokens before burning. Trading additionally requires the same two approvals targeting the NegRiskCtfExchange address. - Gas estimates (Polygon, ~April 2026 baseline). Approximate, varies
±30% with calldata size:
splitPosition~ 200–250 k gasmergePositions~ 200–250 k gasredeemPositions(N=2)~ 150–220 k gas (single binary)convertPositions~ 250–400 k gas (depends onindexSetpopcount) At ~50 gweimaxFeePerGas, redeem costs roughly $0.005–$0.02 of MATIC. At Polygon gas spikes (>500 gwei) this can rise 10×; size the arb spread accordingly.
- Resolution dependency on UMA. Redeem only works after the
UmaCtfAdapter has called
reportPayoutsupstream and (for negRisk) the NegRiskOperator has calledreportOutcome. Until thenredeemPositionsreverts withMarketNotResolved/payout-vector-empty. UMA's optimistic oracle has a 2-hour liveness window (default) per question; disputes extend by days. - No-winner / all-NO is an invalid state. NegRisk requires exactly
one question per market to resolve YES. Per the adapter docs and audit:
if the oracle tries to report a second YES,
reportOutcomereverts and the market is stuck pending manual operator action; if all questions go NO the system is "designed to prevent" that scenario but has no automatic refund path. Polymarket's stated stance after past disputes has been no refunds for resolution disagreements. Architect the strategy so you can hold or sell tokens before final resolution if ambiguity emerges. (NegRisk docs, Coindesk – UMA/Polymarket dispute, Mar 2025) - Operator-only
safeTransferFrom. The adapter'ssafeTransferFromhas anonlyAdminmodifier. Don't try to ERC-1155-transfer wrapped positions through the adapter; transfer directly through the underlying ConditionalTokens contract. negRiskAugmentedevents. WhenenableNegRiskis true, new outcomes (questions) can be appended to a marketId after creation. Your "Σ asks" snapshot can become stale if a new candidate is added between detection and trade — re-pull the event before lifting offers.- CLOB order flag. When placing orders against negRisk markets, you
must pass
neg_risk=Truein the order options ofpy-clob-clientso the order is signed for the NegRiskCtfExchange (0xC5d5…80a) instead of the vanilla CTFExchange. Wrong exchange → orders rejected. (NegRisk overview)
Sources
- Polymarket Contract Addresses
- Polymarket NegRisk Overview
- Polymarket CTF Overview
- neg-risk-ctf-adapter (repo)
NegRiskAdapter.soldocs/NegRiskAdapter.mdCTHelpers.sol- ctf-exchange (repo)
- ChainSecurity NegRiskAdapter audit (Apr 2024)
- Polymarket Resolution docs
- Coindesk: Polymarket/UMA Ukraine bet dispute (Mar 2025)
- PolygonScan verifications: NegRiskAdapter, NegRiskCtfExchange, ConditionalTokens, USDC.e