Polytraders Dev Guide
internal
v3 spine Phase 1 · Shared contracts 9 demo-wired · 0 shadow-ready · 0 production-live · 100 pending · 109 total 15/33 infra tasks the plan status board
HomeBy LayerSecurity5.8 ContractAddressGuard

5.8 ContractAddressGuard

Security Guardrail RejectPause PLANNED Ready to build capital · Direct P5 · Execution rails pending flagship reference bot

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

Docs27/27
donehow scored
Impl0/15
pendinghow scored
Backtest0/4
pendinghow scored
Runtime0/8
pendinghow scored

A bot is done when all four scores are. What does done mean?

1. Bot Identity

LayerSecurity  Security
Bot classGuardrail
AuthorityRejectPause
StatusPLANNED
ReadinessReady to build
Runs beforeAny order signing or submission
Runs afterStrategy OrderIntent and all Risk guardrails
Applies toEvery pending order before signature or on-chain submission
Default modeshadow_only
User-visibleAdvanced details only
Developer ownerPolytraders core — Security pod

Operational profile

Modes supportedquarantine

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

InputSourceRequired?Use
CLOB V2 Exchange contract addresses by chainon-chainYesBuild and maintain the allow-list of valid contract addresses that orders may target.
EIP-712 domain separator for the V2 CLOB exchangeon-chainYesVerify that the domain separator in the pending order matches the expected V2 domain before allowing signature.
Order-type schema of the pending intentCLOBYesConfirm the order conforms to the V2 order schema specification; V1 schema orders are rejected regardless of address.

5. Required Internal Inputs

InputSourceRequired?Use
Committed V2 address allow-listAdmin UIYesAuthoritative list of permitted contract addresses and chain IDs; must be signed off before any address is added or removed.
KillSwitch active flagKillSwitchYesReject all orders immediately if KillSwitch is active, before address checks run.

6. Parameter Guide

ParameterDefaultWarningHardWhat 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_signingTrueNoneNoneWhen true (locked), any order carrying a detected V1 Exchange address is rejected outright, even if somehow present on the allow-list.
require_domain_matchTrueNoneNoneWhen 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_blockTrueNoneNoneWhen 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

ConditionAction
order.contract_address in v2_addresses AND chainId matchesAPPROVE — proceed to signing
order.contract_address NOT in v2_addressesREJECT — CONTRACT_ADDRESS_NOT_ALLOWED
v2_addresses is emptyREJECT — 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

ConditionAction
block_v1_signing=true AND V1 address detectedREJECT — CONTRACT_ADDRESS_NOT_ALLOWED
V1 address not detectedProceed 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

ConditionAction
require_domain_match=true AND domain separator matchesAPPROVE — proceed
require_domain_match=true AND domain separator mismatchREJECT — 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

ConditionAction
alert_on_block=true AND order rejectedEmit security alert with order metadata to monitoring
alert_on_block=falseSilent 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

  1. Receive a pending order or OrderIntent before it reaches the signing step.
  2. Check KillSwitch active flag; if active, return REJECT immediately with KILL_SWITCH_ACTIVE.
  3. Check whether v2_addresses is non-empty; if empty, return REJECT with CONTRACT_ADDRESS_NOT_ALLOWED and log a configuration alert.
  4. 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.
  5. Check order.contract_address and order.chainId against the v2_addresses allow-list; if not found, return REJECT with CONTRACT_ADDRESS_NOT_ALLOWED.
  6. 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.
  7. Validate order schema against the V2 order specification; if the order uses V1 schema fields, return REJECT with CONTRACT_ADDRESS_NOT_ALLOWED.
  8. If alert_on_block=true and any reject was issued, emit a security alert to the monitoring stack with full order metadata.
  9. Log the decision (approve or reject), the checked address, chain ID, and timestamp to the governance audit trail via BuilderAttribution.
  10. 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

HelperSignaturePurpose
buildOrderTypedDatabuildOrderTypedData(intent, domain) -> TypedDataConstructs the EIP-712 typed data structure for an order; ContractAddressGuard validates the domain.verifyingContract field against the allow-list.
fetchClobPublicfetchClobPublic(path: str) -> JSONReads market metadata from CLOB for V2 schema validation context.
isStaleisStale(snapshot: any, maxAgeS: int) -> boolUsed to detect stale allow-list cache during cold starts.
toUsdcUnitstoUsdcUnits(rawUsd: float) -> intNot 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

CodeSeverityMeaningActionUser-facing message
KILL_SWITCH_ACTIVEHARD_REJECTGlobal kill switch is active.Immediately return DENY with security alert.Trading is currently paused.
CONTRACT_ADDRESS_NOT_ALLOWEDHARD_REJECTOrder 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_DENIEDHARD_REJECTThe 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_DETECTEDHARD_REJECTOrder 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_MISMATCHHARD_REJECTEIP-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_SCHEMAHARD_REJECTOrder 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_EMPTYHARD_REJECTThe 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_APPROVALHARD_REJECTAn 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

MetricTypeUnitLabelsMeaning
polytraders_sec_contractaddressguard_decisions_totalcountercountdecision, reason_codeTotal SecurityCheck decisions by type.
polytraders_sec_contractaddressguard_alerts_totalcountercountreasonTotal security alerts emitted, broken down by reason code.
polytraders_sec_contractaddressguard_v1_blocks_totalcountercountCount of orders blocked specifically because a V1 address was detected. Should be zero in normal V2 operation.
polytraders_sec_contractaddressguard_allow_list_sizegaugecountCurrent number of addresses in the V2 allow-list; should be 3 (CTFExchangeV2, NegRiskAdapter, pUSD).
polytraders_sec_contractaddressguard_eval_latency_mshistogramsecondsWall-clock latency of the full address check.

Alerts

AlertConditionSeverityRunbook
ContractAddressGuardBlockrate(polytraders_sec_contractaddressguard_alerts_total[5m]) > 0P0#runbook-contractguard-block
ContractAddressGuardV1Detectedrate(polytraders_sec_contractaddressguard_v1_blocks_total[5m]) > 0P0#runbook-contractguard-v1
ContractAddressGuardAllowListEmptypolytraders_sec_contractaddressguard_allow_list_size == 0P0#runbook-contractguard-allow-list
ContractAddressGuardHighLatencyhistogram_quantile(0.99, rate(polytraders_sec_contractaddressguard_eval_latency_ms_bucket[5m])) > 50P2#runbook-contractguard-latency

Dashboards

  • Grafana — Security / ContractAddressGuard
  • Grafana — V1 to V2 migration / address block history

Log levels

LevelWhat gets logged
DEBUGEach address check result including matched allow-list label.
INFOALLOW decisions with contract label. DENY decisions always at WARN or higher.
WARNDENY decision emitted; security alert raised.
ERRORAllow-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

SituationUser-facing explanation
Order blocked — unknown contract addressThis 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 contractThis 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 mismatchThe 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 configuredThe 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 raisedThis order was blocked and flagged for security review. No funds were moved. The security team has been notified.

18. Failure-Mode Block

main_failure_modeAn 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_riskRejecting 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_riskApproving 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_fallbackIf 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_dependenciesAdmin 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

ScenarioHow to injectExpected behaviourRecovery
V1_ADDRESS_IN_ORDERSubmit a pendingOrder with contract_address = V1 CTFExchange addressImmediate DENY(CONTRACT_ADDRESS_NOT_ALLOWED) + security alert; no signing occursAutomatic on next valid order.
DOMAIN_VERSION_MISMATCHSet pendingOrder.eip712_domain_version = '1'DENY(CONTRACT_ADDRESS_NOT_ALLOWED) + security alertAutomatic on next order with correct domain version.
ALLOW_LIST_EMPTYStart bot with empty v2_addresses configDENY on all orders + configuration alert; no orders proceed to signingAdmin must populate allow-list via signed admin action.
V1_SCHEMA_FIELDSInclude nonce or feeRateBps field in pendingOrderDENY(CONTRACT_ADDRESS_NOT_ALLOWED) + security alertAutomatic on next order without V1 fields.
WRONG_CHAIN_IDSet pendingOrder.chain_id = 1 (Ethereum mainnet)DENY(CONTRACT_ADDRESS_NOT_ALLOWED) — no Polygon allow-list entry matches chain_id=1Automatic on next order with chain_id=137.
KILL_SWITCH_ONSet killswitch.active=trueDENY(KILL_SWITCH_ACTIVE) + security alert without allow-list checkManual 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

AspectSpecification
Execution modelsingle-threaded event loop
Max in-flight500
Idempotency keyintent_id
Replay-safeTrue
Deduplicationby intent_id within a 24h window
Ordering guaranteesno ordering — check is fully stateless
Per-call timeout (ms)20
Backpressure strategydrop newest
Locking / mutual exclusionnone

22. Dependencies

Depends on (must run first)

BotWhyContract
risk.kill_switchKillSwitch gate is checked before any address validation.DENY(KILL_SWITCH_ACTIVE) short-circuits the allow-list check.

Emits to (downstream consumers)

BotWhyContract
gov.builder_attributionEvery SecurityCheck result is logged to the governance audit trail.GovernanceLog entry emitted on both ALLOW and DENY.
exec.smart_routerOnly orders that pass ALLOW proceed to signing and SmartRouter execution.DENY prevents any ExecutionPlan from being constructed.

Used by (auto-aggregated)

2.1 5.3

External services

ServiceEndpointSLA assumedOn failure
On-chain Polygon RPC (read)Polygon RPCbest-effortFalls back to cached allow-list; if cache is empty, DENY.
CLOB API (read)https://clob.polymarket.com99.95% / 200ms p99Order 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

ContractMethodNetworkEffect
CTFExchangeV2matchOrders(...)polygonContractAddressGuard validates that the target contract address matches CTFExchangeV2 before signing is permitted. It does not call matchOrders itself.
NegRiskAdapterconvertPosition(...)polygonFor 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

AspectValue
CLOB versionv2
Collateral assetpUSD
EIP-712 Exchange domain version2
Aware of builderCode fieldyes
Aware of negative-risk marketsyes
Multi-chain readyno
SDK used@polymarket/clob-client-v2 ^2.x
Settlement contractCTFExchangeV2 on Polygon
NotesThis 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

clob_publiconchain

Networks supported

polygon

25. Versioning & Migration

FieldValue
spec2.0.0
implementation2.1.3
schema2
released2026-04-28

Migration history

DateFromToReasonAction taken
2026-04-28v1 (USDC.e + HMAC builder)v2 (pUSD + builderCode field)Polymarket V2 cutoverAdded 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

TestSetupExpected result
Approve when address is in allow-list and domain matchesorder.contract_address=0xVALID, chainId=137, domainSeparator=EXPECTED_V2APPROVE
Reject when address is not in allow-listorder.contract_address=0xUNKNOWN, allow_list=[0xVALID]REJECT with reason_code=CONTRACT_ADDRESS_NOT_ALLOWED
Reject when V1 address detected and block_v1_signing=trueorder.contract_address=0xV1_EXCHANGE, block_v1_signing=trueREJECT with reason_code=CONTRACT_ADDRESS_NOT_ALLOWED and alert_emitted=true
Reject when domain separator does not matchorder.contract_address=0xVALID, domainSeparator=WRONG_DOMAINREJECT with reason_code=CONTRACT_ADDRESS_NOT_ALLOWED
Reject when v2_addresses list is emptyv2_addresses=[]REJECT with reason_code=CONTRACT_ADDRESS_NOT_ALLOWED and configuration alert
Reject when chainId does not match even if address matchesorder.contract_address=0xVALID on chainId=1 but allow_list has chainId=137 onlyREJECT with reason_code=CONTRACT_ADDRESS_NOT_ALLOWED
Alert is emitted on every reject when alert_on_block=trueany reject scenario, alert_on_block=trueSecurity alert emitted with full order metadata

Integration Tests

TestExpected result
Order signing step is never reached when address is not in allow-listREJECT before signing; no signature produced; alert emitted to monitoring
Allow-list update via Admin UI immediately reflected in subsequent checksOrder previously rejected is approved after valid address is added to allow-list via Admin UI
KillSwitch active bypasses allow-list check and rejects immediatelyREJECT with KILL_SWITCH_ACTIVE without reading the allow-list

Property Tests

PropertyRequired behaviour
An empty allow-list always produces REJECT — never APPROVEAlways 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-listAlways true — V1 block takes precedence over the allow-list
Every REJECT emits a security alert when alert_on_block=trueAlways 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

AlertFirst stepDiagnosisMitigationEscalate to
ContractAddressGuardBlockExamine 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.
ContractAddressGuardV1DetectedIdentify 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.
ContractAddressGuardAllowListEmptyCheck 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

GateHow measuredThreshold
Unit tests pass for all address check paths including V1 deny-listCI test run100% pass
Allow-list populated with correct V2 addresses in stagingManual config checkPass

Promote to Limited live

GateHow measuredThreshold
Zero security alerts in shadow mode over 48h of synthetic trafficGrafana ContractAddressGuardBlock alert history0 alerts
V1 address injection test fires DENY + alert correctlyFailure injection testPass

Promote to General live

GateHow measuredThreshold
Domain version mismatch test fires DENY + alert correctlyFailure injection testPass
Allow-list-empty fail-closed test verified: all orders blocked with no signingFailure injection testPass

29. Developer Checklist

Ready-to-ship score: 27/27 sections complete · 100%

RequirementStatus
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