5.8 ContractAddressGuard
ContractAddressGuard refuses to allow a signature or on-chain submission against any contract address that is not present on the committed CLOB V2 allow-list. It enforces the EIP-712 domain separator match against the expected V2 domain and rejects any order that targets a V1 Exchange address. This is a hard security control for the V1-to-V2 migration and must not be loosened without an explicit signed-off admin change. It cannot modify orders — it only approves or rejects.
v3 readiness
A bot is done when all four scores are. What does done mean?
1. Bot Identity
| Layer | Security Security |
|---|---|
| Bot class | Guardrail |
| Authority | RejectPause |
| Status | PLANNED |
| Readiness | Ready to build |
| Runs before | Any order signing or submission |
| Runs after | Strategy OrderIntent and all Risk guardrails |
| Applies to | Every pending order before signature or on-chain submission |
| Default mode | shadow_only |
| User-visible | Advanced details only |
| Developer owner | Polytraders core — Security pod |
Operational profile
| Modes supported | quarantine |
|---|
2. Purpose
ContractAddressGuard refuses to allow a signature or on-chain submission against any contract address that is not present on the committed CLOB V2 allow-list. It enforces the EIP-712 domain separator match against the expected V2 domain and rejects any order that targets a V1 Exchange address. This is a hard security control for the V1-to-V2 migration and must not be loosened without an explicit signed-off admin change. It cannot modify orders — it only approves or rejects.
3. Why This Bot Matters
Order signed against V1 contract after migration
Funds are sent to a deprecated contract that may not be monitored, potentially locking or losing assets that cannot be recovered through normal settlement.
Unknown contract address accepted
Signing an order against an unrecognised contract is the primary vector for phishing and malicious contract substitution attacks in decentralised trading environments.
Domain separator mismatch not detected
A forged or misconfigured EIP-712 domain separator could cause a valid-looking signature to be replayed on a different contract or chain, leading to unintended asset transfers.
No alert on block
Without an alert every time a suspicious address is blocked, security incidents may not be noticed until significant damage has occurred.
No worked examples on this bot yet. Worked examples are optional but strongly recommended — they turn an abstract failure mode into something a developer can verify in a fixture.
4. Required Polymarket Inputs
| Input | Source | Required? | Use |
|---|---|---|---|
| CLOB V2 Exchange contract addresses by chain | on-chain | Yes | Build and maintain the allow-list of valid contract addresses that orders may target. |
| EIP-712 domain separator for the V2 CLOB exchange | on-chain | Yes | Verify that the domain separator in the pending order matches the expected V2 domain before allowing signature. |
| Order-type schema of the pending intent | CLOB | Yes | Confirm the order conforms to the V2 order schema specification; V1 schema orders are rejected regardless of address. |
5. Required Internal Inputs
| Input | Source | Required? | Use |
|---|---|---|---|
| Committed V2 address allow-list | Admin UI | Yes | Authoritative list of permitted contract addresses and chain IDs; must be signed off before any address is added or removed. |
| KillSwitch active flag | KillSwitch | Yes | Reject all orders immediately if KillSwitch is active, before address checks run. |
6. Parameter Guide
| Parameter | Default | Warning | Hard | What it controls |
|---|---|---|---|---|
| v2_addresses | [] | — | — | The committed list of CLOB V2 Exchange contract addresses (address + chainId pairs) that orders are permitted to target. This list is locked and can only be changed via a signed admin action. |
| block_v1_signing | True | None | None | When true (locked), any order carrying a detected V1 Exchange address is rejected outright, even if somehow present on the allow-list. |
| require_domain_match | True | None | None | When true (locked), the EIP-712 domain separator in the order must exactly match the expected V2 domain separator for the given chain. Any mismatch causes an immediate reject. |
| alert_on_block | True | None | None | When true (locked), every blocked order triggers an immediate security alert to the monitoring stack with the full order metadata and blocked address. |
7. Detailed Parameter Instructions
v2_addresses
What it means
The committed list of CLOB V2 Exchange contract addresses (address + chainId pairs) that orders are permitted to target. This list is locked and can only be changed via a signed admin action.
Default
{ "v2_addresses": "[]" }
Why this default matters
The default is an empty list, which means no orders are permitted until the list is explicitly populated and locked by an admin. This fail-closed default prevents accidental signing on an unconfigured deployment.
Threshold logic
| Condition | Action |
|---|---|
| order.contract_address in v2_addresses AND chainId matches | APPROVE — proceed to signing |
| order.contract_address NOT in v2_addresses | REJECT — CONTRACT_ADDRESS_NOT_ALLOWED |
| v2_addresses is empty | REJECT — CONTRACT_ADDRESS_NOT_ALLOWED (not configured) |
Developer check
if (!p.v2_addresses.some(a => a.address === order.contract_address && a.chainId === order.chainId)) return reject('CONTRACT_ADDRESS_NOT_ALLOWED');
User-facing English
This order targeted a contract address that is not on the approved list. It was blocked for your security.
block_v1_signing
What it means
When true (locked), any order carrying a detected V1 Exchange address is rejected outright, even if somehow present on the allow-list.
Default
{ "block_v1_signing": true }
Why this default matters
V1 contracts are deprecated. No legitimate order should target them after the migration. This parameter is locked to prevent accidental downgrade.
Threshold logic
| Condition | Action |
|---|---|
| block_v1_signing=true AND V1 address detected | REJECT — CONTRACT_ADDRESS_NOT_ALLOWED |
| V1 address not detected | Proceed to other checks |
Developer check
if (p.block_v1_signing && V1_ADDRESSES.has(order.contract_address)) return reject('CONTRACT_ADDRESS_NOT_ALLOWED');
User-facing English
This order used an old contract version. It was blocked as part of the exchange upgrade process.
require_domain_match
What it means
When true (locked), the EIP-712 domain separator in the order must exactly match the expected V2 domain separator for the given chain. Any mismatch causes an immediate reject.
Default
{ "require_domain_match": true }
Why this default matters
Domain separator mismatches are a primary indicator of a replay attack or a misconfigured client. Accepting orders with wrong domain separators undermines the security of EIP-712 signing.
Threshold logic
| Condition | Action |
|---|---|
| require_domain_match=true AND domain separator matches | APPROVE — proceed |
| require_domain_match=true AND domain separator mismatch | REJECT — CONTRACT_ADDRESS_NOT_ALLOWED (domain mismatch) |
Developer check
if (p.require_domain_match && order.domainSeparator !== EXPECTED_V2_DOMAIN) return reject('CONTRACT_ADDRESS_NOT_ALLOWED');
User-facing English
This order contained an unexpected security signature. It was blocked to protect your wallet.
alert_on_block
What it means
When true (locked), every blocked order triggers an immediate security alert to the monitoring stack with the full order metadata and blocked address.
Default
{ "alert_on_block": true }
Why this default matters
Security rejections are high-signal events. Every block may indicate an attempted attack or a client misconfiguration. Alerting on every block ensures none are missed.
Threshold logic
| Condition | Action |
|---|---|
| alert_on_block=true AND order rejected | Emit security alert with order metadata to monitoring |
| alert_on_block=false | Silent reject — not recommended |
Developer check
if (p.alert_on_block && decision === 'REJECT') alerting.emit('SECURITY_BLOCK', { order, reason });
User-facing English
This order was blocked and flagged for review by the security system.
8. Default Configuration
{
"bot_id": "sec.contract_address_guard",
"version": "1.0.0",
"mode": "hard_guard",
"defaults": {
"v2_addresses": [],
"block_v1_signing": true,
"require_domain_match": true,
"alert_on_block": true
},
"locked": {
"block_v1_signing": {
"immutable": true
},
"require_domain_match": {
"immutable": true
},
"alert_on_block": {
"immutable": true
},
"v2_addresses": {
"change_requires_signed_admin_action": true
}
}
}9. Implementation Flow
- Receive a pending order or OrderIntent before it reaches the signing step.
- Check KillSwitch active flag; if active, return REJECT immediately with KILL_SWITCH_ACTIVE.
- Check whether v2_addresses is non-empty; if empty, return REJECT with CONTRACT_ADDRESS_NOT_ALLOWED and log a configuration alert.
- If block_v1_signing=true, check whether order.contract_address matches any known V1 Exchange address; if matched, return REJECT with CONTRACT_ADDRESS_NOT_ALLOWED.
- Check order.contract_address and order.chainId against the v2_addresses allow-list; if not found, return REJECT with CONTRACT_ADDRESS_NOT_ALLOWED.
- If require_domain_match=true, compute the expected EIP-712 domain separator for the V2 contract on the target chain and compare with order.domainSeparator; if mismatch, return REJECT with CONTRACT_ADDRESS_NOT_ALLOWED.
- Validate order schema against the V2 order specification; if the order uses V1 schema fields, return REJECT with CONTRACT_ADDRESS_NOT_ALLOWED.
- If alert_on_block=true and any reject was issued, emit a security alert to the monitoring stack with full order metadata.
- Log the decision (approve or reject), the checked address, chain ID, and timestamp to the governance audit trail via BuilderAttribution.
- Return APPROVE with inputs_used and checked_at timestamp if all checks pass.
10. Reference Implementation
Validates every pending order against a hardcoded V2 allow-list (CTFExchangeV2, NegRiskAdapter, pUSD ERC-20 on Polygon), verifies the EIP-712 domain separator version is '2', and rejects any order targeting a V1 address.
Pseudocode is language-agnostic. FETCH = read input. EMIT = produce output. Translate to TS/Python/Go/Rust.
// V2 allow-list — chain ID 137 (Polygon)
CONST V2_ALLOW_LIST = [
{ address: '0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E', label: 'CTFExchangeV2' },
{ address: '0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296', label: 'NegRiskAdapter' },
{ address: '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174', label: 'pUSD ERC-20' }
]
CONST V1_DENY_LIST = [ '0xC5d563A36AE78145C45a50134d48A1A61A3A4Dc7' ] // old CTFExchange
CONST EXPECTED_EIP712_DOMAIN_VERSION = '2'
FUNCTION checkContractAddress(pendingOrder):
// --- 0. KillSwitch gate ---
ks = FETCH internal.killswitch.status
IF ks.active:
EMIT SecurityCheck(decision=DENY, reason=KILL_SWITCH_ACTIVE)
alerting.emit('SECURITY_BLOCK', { pendingOrder, reason: KILL_SWITCH_ACTIVE })
RETURN
// --- 1. V2 allow-list non-empty check ---
IF V2_ALLOW_LIST.isEmpty():
EMIT SecurityCheck(decision=DENY, reason=CONTRACT_ADDRESS_NOT_ALLOWED)
alerting.emit('SECURITY_BLOCK', { pendingOrder, reason: 'allow_list_empty' })
RETURN
// --- 2. V1 deny-list check ---
IF V1_DENY_LIST.includes(pendingOrder.contract_address):
EMIT SecurityCheck(decision=DENY, reason=CONTRACT_ADDRESS_NOT_ALLOWED)
alerting.emit('SECURITY_BLOCK', { pendingOrder, reason: 'v1_address_detected' })
RETURN
// --- 3. V2 allow-list membership check ---
matched = V2_ALLOW_LIST.find(
a => a.address == pendingOrder.contract_address
AND pendingOrder.chain_id == 137
)
IF NOT matched:
EMIT SecurityCheck(decision=DENY, reason=CONTRACT_ADDRESS_NOT_ALLOWED)
alerting.emit('SECURITY_BLOCK', { pendingOrder, reason: 'not_in_allow_list' })
RETURN
// --- 4. EIP-712 domain separator check ---
// Domain version must be '2'; ClobAuth domain stays '1'
IF pendingOrder.eip712_domain_version != EXPECTED_EIP712_DOMAIN_VERSION:
EMIT SecurityCheck(decision=DENY, reason=CONTRACT_ADDRESS_NOT_ALLOWED)
alerting.emit('SECURITY_BLOCK', { pendingOrder, reason: 'domain_version_mismatch' })
RETURN
// --- 5. V2 order schema check ---
// V2 schema: timestamp + metadata(bytes32) + builder(bytes32)
// V1 fields nonce/feeRateBps/taker must be absent
IF pendingOrder.nonce IS NOT NULL OR pendingOrder.feeRateBps IS NOT NULL:
EMIT SecurityCheck(decision=DENY, reason=CONTRACT_ADDRESS_NOT_ALLOWED)
alerting.emit('SECURITY_BLOCK', { pendingOrder, reason: 'v1_schema_fields_present' })
RETURN
// --- 6. Log to governance audit trail ---
EMIT GovernanceLog(event=SECURITY_CHECK_PASSED, order=pendingOrder, allow_list_label=matched.label)
// --- 7. Happy path ---
EMIT SecurityCheck(decision=ALLOW, checked_at=now_iso())
Helpers used
| Helper | Signature | Purpose |
|---|---|---|
| buildOrderTypedData | buildOrderTypedData(intent, domain) -> TypedData | Constructs the EIP-712 typed data structure for an order; ContractAddressGuard validates the domain.verifyingContract field against the allow-list. |
| fetchClobPublic | fetchClobPublic(path: str) -> JSON | Reads market metadata from CLOB for V2 schema validation context. |
| isStale | isStale(snapshot: any, maxAgeS: int) -> bool | Used to detect stale allow-list cache during cold starts. |
| toUsdcUnits | toUsdcUnits(rawUsd: float) -> int | Not called directly; imported for consistency with Security pod SDK setup. |
SDK calls used
buildOrderTypedData(pendingOrder, { name: 'CTFExchange', version: '2', chainId: 137, verifyingContract: '0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E' })fetchClobPublic('/markets/' + pendingOrder.market_id)internal.killswitch.status()alerting.emit('SECURITY_BLOCK', metadata)
Complexity: O(1) per order — allow-list lookup is a constant-size set
11. Wire Examples
Input — what arrives on the wire
Pending order targeting CTFExchangeV2 (valid) — internal
{
"intent_id": "int_5e6f7a8b9c0d1e2f",
"contract_address": "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E",
"chain_id": 137,
"eip712_domain_version": "2",
"market_id": "0x5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e",
"side": "BUY",
"size_usd": 400,
"timestamp_ms": 1746768672000,
"metadata": "0x0000000000000000000000000000000000000000000000000000000000000000",
"builder": "0x706f6c7974726164657273000000000000000000000000000000000000000000"
}
Pending order targeting V1 address (should DENY) — internal
{
"intent_id": "int_3c4d5e6f7a8b9c0d",
"contract_address": "0xC5d563A36AE78145C45a50134d48A1A61A3A4Dc7",
"chain_id": 137,
"eip712_domain_version": "1",
"nonce": "12345"
}
Output — what the bot emits
SecurityCheck — ALLOW (V2 address, valid schema)
{
"check_id": "sec.contract_address_guard.20260509T100000Z",
"scope": "contract",
"decision": "ALLOW",
"reason_code": null,
"evidence": {
"submitted_address": "0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E",
"chain_id": 137,
"allow_list_version": "v2.2026-04-28",
"allow_list_label": "CTFExchangeV2",
"allow_list_match": true,
"domain_version_ok": true,
"v2_schema_ok": true,
"alert_raised": false
},
"checked_at": "2026-05-09T10:00:00Z"
}
SecurityCheck — DENY (V1 address detected)
{
"check_id": "sec.contract_address_guard.20260509T100500Z",
"scope": "contract",
"decision": "DENY",
"reason_code": "CONTRACT_ADDRESS_NOT_ALLOWED",
"evidence": {
"submitted_address": "0xC5d563A36AE78145C45a50134d48A1A61A3A4Dc7",
"chain_id": 137,
"allow_list_version": "v2.2026-04-28",
"allow_list_match": false,
"v1_address_detected": true,
"alert_raised": true
},
"checked_at": "2026-05-09T10:05:00Z"
}
Reproduce locally
curl 'https://clob.polymarket.com/markets/0x5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e'12. Decision Logic
APPROVE
order.contract_address is present in v2_addresses with matching chainId, EIP-712 domain separator matches the expected V2 domain, order schema is V2-compliant, and no V1 address is detected.
RESHAPE_REQUIRED
Not applicable — ContractAddressGuard does not reshape orders. An order either targets a permitted contract or it does not.
REJECT
Contract address not in allow-list (CONTRACT_ADDRESS_NOT_ALLOWED), V1 address detected (CONTRACT_ADDRESS_NOT_ALLOWED), domain separator mismatch (CONTRACT_ADDRESS_NOT_ALLOWED), allow-list is empty (CONTRACT_ADDRESS_NOT_ALLOWED), or KillSwitch active (KILL_SWITCH_ACTIVE).
WARNING_ONLY
Not used — ContractAddressGuard has reject authority. All anomalies result in a hard reject and an alert.
13. Standard Decision Output
This bot returns a SecurityCheck object. See SecurityCheck schema.
{
"check_id": "sec.contract_address_guard.20260509T100000Z",
"scope": "contract",
"decision": "DENY",
"reason_code": "CONTRACT_ADDRESS_NOT_ALLOWED",
"evidence": {
"submitted_address": "0xDEAD1234",
"chain_id": 137,
"allow_list_version": "v2.2026-05-08",
"allow_list_match": false,
"alert_raised": true
},
"checked_at": "2026-05-09T10:00:00Z"
}14. Reason Codes
| Code | Severity | Meaning | Action | User-facing message |
|---|---|---|---|---|
KILL_SWITCH_ACTIVE | HARD_REJECT | Global kill switch is active. | Immediately return DENY with security alert. | Trading is currently paused. |
CONTRACT_ADDRESS_NOT_ALLOWED | HARD_REJECT | Order targets a contract address not in the V2 allow-list, a V1 address, an empty allow-list, or has a domain separator mismatch. | Return DENY and emit security alert with full metadata. | This order was blocked because it targeted an unrecognised contract address. |
WALLET_PERMISSION_DENIED | HARD_REJECT | The signing wallet does not have permission to submit to the target contract (chain_id or permissions mismatch). | Return DENY without proceeding to signing. | Your wallet does not have permission to interact with this contract. |
CONTRACT_GUARD_V1_DETECTED | HARD_REJECT | Order carries a known V1 CTFExchange address; block_v1_signing is locked true. | Return DENY; emit security alert with submitted_address. | This order used an older contract version. It was blocked automatically. |
CONTRACT_GUARD_DOMAIN_MISMATCH | HARD_REJECT | EIP-712 domain separator version is not '2' (e.g., still '1' from V1 SDK). | Return DENY; emit security alert with domain details. | The security parameters in this order did not match the current exchange. The order was blocked. |
CONTRACT_GUARD_V1_SCHEMA | HARD_REJECT | Order contains V1-only fields (nonce, feeRateBps, taker) that must be absent in V2. | Return DENY; emit security alert. | This order contained outdated fields. Please update the SDK to V2. |
CONTRACT_GUARD_ALLOW_LIST_EMPTY | HARD_REJECT | The V2 allow-list has not been configured; fail-closed default. | Return DENY; emit configuration alert. | The approved contract list has not been set up yet. No orders can be placed until it is configured. |
PARAMETER_CHANGE_REQUIRES_APPROVAL | HARD_REJECT | An attempt was made to modify a locked parameter (block_v1_signing, require_domain_match, alert_on_block, or v2_addresses) without a signed admin action. | Reject the configuration change and emit an alert. |
15. Metrics & Logs
Metrics emitted
| Metric | Type | Unit | Labels | Meaning |
|---|---|---|---|---|
polytraders_sec_contractaddressguard_decisions_total | counter | count | decision, reason_code | Total SecurityCheck decisions by type. |
polytraders_sec_contractaddressguard_alerts_total | counter | count | reason | Total security alerts emitted, broken down by reason code. |
polytraders_sec_contractaddressguard_v1_blocks_total | counter | count | Count of orders blocked specifically because a V1 address was detected. Should be zero in normal V2 operation. | |
polytraders_sec_contractaddressguard_allow_list_size | gauge | count | Current number of addresses in the V2 allow-list; should be 3 (CTFExchangeV2, NegRiskAdapter, pUSD). | |
polytraders_sec_contractaddressguard_eval_latency_ms | histogram | seconds | Wall-clock latency of the full address check. |
Alerts
| Alert | Condition | Severity | Runbook |
|---|---|---|---|
ContractAddressGuardBlock | rate(polytraders_sec_contractaddressguard_alerts_total[5m]) > 0 | P0 | #runbook-contractguard-block |
ContractAddressGuardV1Detected | rate(polytraders_sec_contractaddressguard_v1_blocks_total[5m]) > 0 | P0 | #runbook-contractguard-v1 |
ContractAddressGuardAllowListEmpty | polytraders_sec_contractaddressguard_allow_list_size == 0 | P0 | #runbook-contractguard-allow-list |
ContractAddressGuardHighLatency | histogram_quantile(0.99, rate(polytraders_sec_contractaddressguard_eval_latency_ms_bucket[5m])) > 50 | P2 | #runbook-contractguard-latency |
Dashboards
- Grafana — Security / ContractAddressGuard
- Grafana — V1 to V2 migration / address block history
Log levels
| Level | What gets logged |
|---|---|
| DEBUG | Each address check result including matched allow-list label. |
| INFO | ALLOW decisions with contract label. DENY decisions always at WARN or higher. |
| WARN | DENY decision emitted; security alert raised. |
| ERROR | Allow-list empty on startup; Admin UI unreachable; V1 address detected in live traffic. |
16. Developer Reporting
{
"bot_id": "sec.contract_address_guard",
"decision": "DENY",
"reason_code": "CONTRACT_ADDRESS_NOT_ALLOWED",
"inputs_used": [
"clob.order_schema",
"admin_ui.v2_allow_list"
],
"metrics": {
"submitted_address": "0xDEAD1234",
"allow_list_size": 4,
"match": false
},
"checked_at": "2026-05-09T10:00:00Z"
}17. Plain-English Reporting
| Situation | User-facing explanation |
|---|---|
| Order blocked — unknown contract address | This order was directed at a contract address that is not on the approved list. It was blocked to protect your funds from being sent to an unverified contract. |
| Order blocked — old exchange contract | This order was targeting an older version of the exchange contract. All trading now goes through the current version. The order was blocked automatically. |
| Order blocked — security signature mismatch | The security parameters in this order did not match what is expected for the current exchange. The order was blocked to prevent a potential signing issue. |
| Order blocked — allow-list not configured | The approved contract list has not been set up yet. No orders can be placed until the list is configured and verified. |
| Order blocked — security alert raised | This order was blocked and flagged for security review. No funds were moved. The security team has been notified. |
18. Failure-Mode Block
| main_failure_mode | An order being signed against a non-V2 contract because the allow-list check is bypassed or the list is misconfigured, enabling unintended asset transfers. |
|---|---|
| false_positive_risk | Rejecting a legitimate order because the V2 address list has not yet been populated after a contract upgrade, requiring an admin action to unblock trading. |
| false_negative_risk | Approving an order against a stale allow-list that does not yet reflect a contract deprecation if the list cache is not refreshed promptly after an admin change. |
| safe_fallback | If the v2_addresses list is empty or cannot be loaded, reject all orders with CONTRACT_ADDRESS_NOT_ALLOWED. An unconfigured allow-list is fail-closed. If admin UI is unreachable, continue using the last cached list and alert. |
| required_dependencies | Admin UI v2 allow-list with signed admin approval, On-chain V2 contract address registry, KillSwitch active flag, BuilderAttribution governance audit log |
19. Failure-Injection Recipes
| Scenario | How to inject | Expected behaviour | Recovery |
|---|---|---|---|
V1_ADDRESS_IN_ORDER | Submit a pendingOrder with contract_address = V1 CTFExchange address | Immediate DENY(CONTRACT_ADDRESS_NOT_ALLOWED) + security alert; no signing occurs | Automatic on next valid order. |
DOMAIN_VERSION_MISMATCH | Set pendingOrder.eip712_domain_version = '1' | DENY(CONTRACT_ADDRESS_NOT_ALLOWED) + security alert | Automatic on next order with correct domain version. |
ALLOW_LIST_EMPTY | Start bot with empty v2_addresses config | DENY on all orders + configuration alert; no orders proceed to signing | Admin must populate allow-list via signed admin action. |
V1_SCHEMA_FIELDS | Include nonce or feeRateBps field in pendingOrder | DENY(CONTRACT_ADDRESS_NOT_ALLOWED) + security alert | Automatic on next order without V1 fields. |
WRONG_CHAIN_ID | Set pendingOrder.chain_id = 1 (Ethereum mainnet) | DENY(CONTRACT_ADDRESS_NOT_ALLOWED) — no Polygon allow-list entry matches chain_id=1 | Automatic on next order with chain_id=137. |
KILL_SWITCH_ON | Set killswitch.active=true | DENY(KILL_SWITCH_ACTIVE) + security alert without allow-list check | Manual KillSwitch reset. |
20. State & Persistence
Stateless per evaluation. The V2 allow-list is loaded from Admin UI configuration at startup and is immutable until a signed admin change.
Cold-start recovery
On cold start, allow-list is loaded from Admin UI config. If config is unreachable, fail-closed: treat allow-list as empty and return DENY on all orders.
On restart
Allow-list is re-loaded from Admin UI on startup. If the last known config is cached locally, that cache is used until Admin UI is reachable.
21. Concurrency & Idempotency
| Aspect | Specification |
|---|---|
| Execution model | single-threaded event loop |
| Max in-flight | 500 |
| Idempotency key | intent_id |
| Replay-safe | True |
| Deduplication | by intent_id within a 24h window |
| Ordering guarantees | no ordering — check is fully stateless |
| Per-call timeout (ms) | 20 |
| Backpressure strategy | drop newest |
| Locking / mutual exclusion | none |
22. Dependencies
Depends on (must run first)
| Bot | Why | Contract |
|---|---|---|
| risk.kill_switch | KillSwitch gate is checked before any address validation. | DENY(KILL_SWITCH_ACTIVE) short-circuits the allow-list check. |
Emits to (downstream consumers)
| Bot | Why | Contract |
|---|---|---|
| gov.builder_attribution | Every SecurityCheck result is logged to the governance audit trail. | GovernanceLog entry emitted on both ALLOW and DENY. |
| exec.smart_router | Only orders that pass ALLOW proceed to signing and SmartRouter execution. | DENY prevents any ExecutionPlan from being constructed. |
Used by (auto-aggregated)
External services
| Service | Endpoint | SLA assumed | On failure |
|---|---|---|---|
| On-chain Polygon RPC (read) | Polygon RPC | best-effort | Falls back to cached allow-list; if cache is empty, DENY. |
| CLOB API (read) | https://clob.polymarket.com | 99.95% / 200ms p99 | Order schema validation uses cached V2 spec if CLOB is unreachable. |
23. Security Surfaces
ContractAddressGuard is the primary defence against phishing, V1 replay, and malicious contract substitution. Every block emits a security alert. The allow-list is change-controlled.
Signing surface
This bot does NOT sign anything. It runs before signing to prevent signing against unapproved contracts.
On-chain contract calls
| Contract | Method | Network | Effect |
|---|---|---|---|
CTFExchangeV2 | matchOrders(...) | polygon | ContractAddressGuard validates that the target contract address matches CTFExchangeV2 before signing is permitted. It does not call matchOrders itself. |
NegRiskAdapter | convertPosition(...) | polygon | For neg-risk convert-arb routes, ContractAddressGuard validates the NegRiskAdapter address is in the allow-list. |
Abuse vectors considered
- Injecting an unknown contract address into an order payload to redirect funds
- Replaying a V1 signed order by substituting the V1 CTFExchange address
- EIP-712 domain separator forgery to redirect signing to a different chain or contract
- Modifying the allow-list by bypassing the signed admin action requirement
Mitigations
- V2 allow-list is hardcoded for Polygon chain_id=137; any other chain_id is rejected
- V1 deny-list check runs before the allow-list check
- EIP-712 domain version must exactly equal '2' — mismatches are blocked and alerted
- V1 schema fields (nonce, feeRateBps, taker) trigger immediate DENY
- Every DENY emits a security alert regardless of alert_on_block setting (it is locked true)
24. Polymarket V2 Compatibility
| Aspect | Value |
|---|---|
| CLOB version | v2 |
| Collateral asset | pUSD |
| EIP-712 Exchange domain version | 2 |
| Aware of builderCode field | yes |
| Aware of negative-risk markets | yes |
| Multi-chain ready | no |
| SDK used | @polymarket/clob-client-v2 ^2.x |
| Settlement contract | CTFExchangeV2 on Polygon |
| Notes | This bot is the hardest V1→V2 migration control point. Allow-list now contains CTFExchangeV2, NegRiskAdapter, and pUSD ERC-20. The old V1 CTFExchange address is on the deny-list. EIP-712 Exchange domain version must be '2'; ClobAuth domain version remains '1' and is not checked here. V2 order fields: timestamp(ms) + metadata(bytes32) + builder(bytes32); nonce/feeRateBps/taker must be absent. |
API surfaces declared
Networks supported
25. Versioning & Migration
| Field | Value |
|---|---|
| spec | 2.0.0 |
| implementation | 2.1.3 |
| schema | 2 |
| released | 2026-04-28 |
Migration history
| Date | From | To | Reason | Action taken |
|---|---|---|---|---|
| 2026-04-28 | v1 (USDC.e + HMAC builder) | v2 (pUSD + builderCode field) | Polymarket V2 cutover | Added CTFExchangeV2, NegRiskAdapter, and pUSD ERC-20 to the allow-list. Added old V1 CTFExchange to the deny-list. EIP-712 domain version check updated from '1' to '2'. Added V1 schema field detection (nonce, feeRateBps, taker). HMAC builder replaced with on-order builderCode bytes32 field. |
26. Acceptance Tests
Unit Tests
| Test | Setup | Expected result |
|---|---|---|
| Approve when address is in allow-list and domain matches | order.contract_address=0xVALID, chainId=137, domainSeparator=EXPECTED_V2 | APPROVE |
| Reject when address is not in allow-list | order.contract_address=0xUNKNOWN, allow_list=[0xVALID] | REJECT with reason_code=CONTRACT_ADDRESS_NOT_ALLOWED |
| Reject when V1 address detected and block_v1_signing=true | order.contract_address=0xV1_EXCHANGE, block_v1_signing=true | REJECT with reason_code=CONTRACT_ADDRESS_NOT_ALLOWED and alert_emitted=true |
| Reject when domain separator does not match | order.contract_address=0xVALID, domainSeparator=WRONG_DOMAIN | REJECT with reason_code=CONTRACT_ADDRESS_NOT_ALLOWED |
| Reject when v2_addresses list is empty | v2_addresses=[] | REJECT with reason_code=CONTRACT_ADDRESS_NOT_ALLOWED and configuration alert |
| Reject when chainId does not match even if address matches | order.contract_address=0xVALID on chainId=1 but allow_list has chainId=137 only | REJECT with reason_code=CONTRACT_ADDRESS_NOT_ALLOWED |
| Alert is emitted on every reject when alert_on_block=true | any reject scenario, alert_on_block=true | Security alert emitted with full order metadata |
Integration Tests
| Test | Expected result |
|---|---|
| Order signing step is never reached when address is not in allow-list | REJECT before signing; no signature produced; alert emitted to monitoring |
| Allow-list update via Admin UI immediately reflected in subsequent checks | Order previously rejected is approved after valid address is added to allow-list via Admin UI |
| KillSwitch active bypasses allow-list check and rejects immediately | REJECT with KILL_SWITCH_ACTIVE without reading the allow-list |
Property Tests
| Property | Required behaviour |
|---|---|
| An empty allow-list always produces REJECT — never APPROVE | Always true — fail-closed on unconfigured deployment |
| A V1 address with block_v1_signing=true always results in REJECT even if somehow present in the allow-list | Always true — V1 block takes precedence over the allow-list |
| Every REJECT emits a security alert when alert_on_block=true | Always true — no silent security rejects |
27. Operational Runbook
Every ContractAddressGuard block is a P0 security event. On-call must treat every block as a potential attack until proven otherwise.
On-call actions
| Alert | First step | Diagnosis | Mitigation | Escalate to |
|---|---|---|---|---|
ContractAddressGuardBlock | Examine the security alert metadata: submitted_address, reason, and intent_id. | If submitted_address matches a known V1 address, the client SDK has not been updated to V2. If it is unknown, treat as potential phishing. | Block the offending client or strategy. Do not modify the allow-list without a security review. | Security pod lead immediately on any DENY alert. |
ContractAddressGuardV1Detected | Identify which strategy submitted the V1-addressed order. | Strategy is using an outdated SDK or configuration. | Pause the strategy and require SDK upgrade to @polymarket/clob-client-v2. | Security pod lead + Risk pod lead. |
ContractAddressGuardAllowListEmpty | Check Admin UI configuration for v2_addresses. | Allow-list was not populated after deployment or a config reset. | Populate the allow-list with the three V2 addresses via signed admin action. All trading is blocked until this is done. | Security pod lead immediately. |
Manual overrides
polytraders admin add-address sec.contract_address_guard --address <addr> --chain-id 137— Adds an address to the V2 allow-list. Requires signed admin action and is audit-logged.polytraders bot status sec.contract_address_guard— Prints current allow-list, deny-list, and last block event.
Healthcheck
GET /health → 200 if allow-list contains at least 3 addresses and no DENY alert has fired in the last 60s.28. Promotion Gates
A bot does not advance to the next readiness state until every gate below is green. Gates are observable from production data — no subjective sign-off.
Promote to Shadow
| Gate | How measured | Threshold |
|---|---|---|
| Unit tests pass for all address check paths including V1 deny-list | CI test run | 100% pass |
| Allow-list populated with correct V2 addresses in staging | Manual config check | Pass |
Promote to Limited live
| Gate | How measured | Threshold |
|---|---|---|
| Zero security alerts in shadow mode over 48h of synthetic traffic | Grafana ContractAddressGuardBlock alert history | 0 alerts |
| V1 address injection test fires DENY + alert correctly | Failure injection test | Pass |
Promote to General live
| Gate | How measured | Threshold |
|---|---|---|
| Domain version mismatch test fires DENY + alert correctly | Failure injection test | Pass |
| Allow-list-empty fail-closed test verified: all orders blocked with no signing | Failure injection test | Pass |
29. Developer Checklist
Ready-to-ship score: 27/27 sections complete · 100%
| Requirement | Status |
|---|---|
| Purpose defined | ✓ done |
| Required inputs listed | ✓ done |
| Parameters defined | ✓ done |
| Defaults defined | ✓ done |
| Warning thresholds defined | ✓ done |
| Hard thresholds defined | ✓ done |
| Safe fallback defined | ✓ done |
| Structured output defined | ✓ done |
| Developer log defined | ✓ done |
| Plain-English explanation | ✓ done |
| Unit tests defined | ✓ done |
| Integration tests defined | ✓ done |
| Property tests defined | ✓ done |
| Failure-mode block complete | ✓ done |
| Reference implementation pseudocode | ✓ done |
| Wire examples (input + output) | ✓ done |
| Reason codes listed | ✓ done |
| Metrics & logs defined | ✓ done |
| State & persistence defined | ✓ done |
| Concurrency & idempotency defined | ✓ done |
| Dependencies declared | ✓ done |
| Security surfaces declared | ✓ done |
| Polymarket V2 compatibility declared | ✓ done |
| Version & migration history declared | ✓ done |
| Operational runbook defined | ✓ done |
| Promotion gates defined | ✓ done |
| Failure-injection recipes defined | ✓ done |