Skip to content

feat: CTF Exchange V2 migration - #24

Open
dkeysil wants to merge 8 commits into
Polymarket:mainfrom
dkeysil:feat/v2-migration
Open

feat: CTF Exchange V2 migration#24
dkeysil wants to merge 8 commits into
Polymarket:mainfrom
dkeysil:feat/v2-migration

Conversation

@dkeysil

@dkeysil dkeysil commented Apr 18, 2026

Copy link
Copy Markdown

Summary

Migrates go-order-utils to CTF Exchange V2 ahead of the 2026-04-22 cutover. After cutover, V1 order signatures will be rejected by the operator (new ORDER_TYPEHASH, new domain version, new contract addresses), so this library must be updated or it becomes non-functional.

What changed

V2 order struct (EIP-712)

11 signed fields in this exact order:

Order(uint256 salt, address maker, address signer, uint256 tokenId,
      uint256 makerAmount, uint256 takerAmount, uint8 side, uint8 signatureType,
      uint256 timestamp, bytes32 metadata, bytes32 builder)

ORDER_TYPEHASH = 0xbb86318a2138f5fa8ae32fbe8e659f8fcf13cc6ae4014a707893055433818589 — pinned by TestOrderStructureHashV2 against the Solidity constant in ctf-exchange-v2.

  • Removed from signed struct: taker, expiration, nonce, feeRateBps
  • Added: timestamp (unix ms, replaces nonce for uniqueness), metadata (bytes32), builder (bytes32 builder code)
  • EIP-712 domain version bumped "1""2"; name unchanged
  • SignatureType gained POLY_1271. For POLY_1271 orders BuildSignedOrder skips the local ecrecover check — the signature is validated on-chain via isValidSignature at fill time, matching py-clob-client-v2 / @polymarket/clob-client-v2 behavior.

Contract addresses (same on Polygon mainnet and Amoy)

  • CTFExchange V2: 0xE111180000d2663C0091e4f400237545B87B996B
  • NegRisk CTFExchange V2: 0xe2222d279d744050d28e00520010520000310F59
  • Collateral → pUSD 0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB

Contract bindings

pkg/contracts/exchange regenerated via abigen from the V2 CTFExchange ABI in @polymarket/clob-client-v2 (src/order-utils/abi/ExchangeV2.ts). Both V2 exchanges deploy the same bytecode, so one binding covers both deployments — callers pass the address from config.GetContracts(...).Exchange or .NegRiskExchange. V1 *-fees packages are not regenerated: V2 removed the standalone fee-module contracts (fees are set at match time by the operator).

Public API changes beyond the V2 struct shape

  • OrderData.Timestamp: stringint64 (zero → use builder's timestamp generator)
  • OrderData.Metadata / .Builder: stringcommon.Hash
  • Order.Side / .SignatureType: *big.Intuint8 (ABI-native; saves two allocations per call)
  • NewExchangeOrderBuilderImpl gained a third positional arg timestampGenerator func() int64 (nil defaults to utils.GenerateTimestampMs)
  • Contracts.FeeModule / .NegRiskFeeModule removed (no V2 equivalent)

Performance

EIP-712 domain separators are precomputed at builder construction and cached per VerifyingContract, saving an abi.Pack + keccak per signing call.

Tooling

  • make lint pins golangci-lint/v2@v2.11.4 (v1.54.2 no longer compiles on modern Go); .golangci.yaml migrated to v2 schema.
  • Linter set: keeps the v1 defaults (errcheck, govet, ineffassign, staticcheck [absorbed gosimple in v2], unused) + gofmt; adds gocyclo, misspell, revive. Two narrow revive exclusions: upperCaseConst: true (for existing POLY_* / EOA / BUY / SELL const names, which match the TS/Py SDKs) and a path-scoped "TokenId should be TokenID" suppression in pkg/model/order.go (field mirrors the JSON wire name).
  • go.mod and CI GO_VERSION bumped to 1.26. 1.21 was EOL since 2024-08 and blocked the modern linter install.

Verification

  • ORDER_TYPEHASH invariantTestOrderStructureHashV2 pins the Go-computed hash to the Solidity constant.
  • Byte-for-byte parity with py-clob-client-v2 v1.0.0 — 5 golden (order_hash, signature) vectors generated via scripts/gen_v2_vectors.py and baked into TestV2Goldens: CTF EOA, NegRisk EOA, CTF POLY_GNOSIS_SAFE, CTF POLY_1271, CTF EOA with non-zero metadata + builder. All pass.
  • POLY_1271 regressionTestBuildSignedOrder_POLY1271SkipsEcrecover exercises the Signer ≠ EOA path that errored before the local-validation fix.
  • make lint0 issues. make test → green. go test -race -count=1 ./... → green. go mod verify → clean.

V2 contracts aren't deployed yet (cutover is 2026-04-22), so end-to-end testing against a live exchange isn't possible pre-merge; parity with the reference Python SDK + independent ORDER_TYPEHASH validation against Solidity are the strongest pre-deployment checks available.

Test plan

  • CI lint-test passes on Go 1.26
  • Review V2 order struct order + ORDER_TYPEHASH vs ctf-exchange-v2 source
  • Review regenerated pkg/contracts/exchange bindings
  • Smoke-run a signed order against clob-v2.polymarket.com preprod (reviewer with Amoy creds)

Consumer migration note

Downstream code using OrderData{Taker, FeeRateBps, Nonce, Expiration} won't compile; timestamp / metadata / builder are optional (defaults: now-ms / zero / zero), so migration is mostly a field rename pass.


Note

High Risk
Changes the cryptographic signing/hashing schema and public order/config types, so any mismatch with the on-chain V2 spec will invalidate signatures and break downstream integrations.

Overview
Updates the order builder to CTF Exchange V2 by changing the EIP-712 domain/version and signed struct fields (removing taker/expiration/nonce/feeRateBps, adding timestamp/metadata/builder, and switching Side/SignatureType to uint8), plus caching domain separators per verifying contract and skipping local ecrecover validation for POLY_1271 signatures.

Refreshes protocol configuration to V2 contract addresses (same exchange addresses on Polygon + Amoy), removes fee-module fields from Contracts, and drops the generated V1 exchange-fees binding. Adds new tests that pin ORDER_TYPEHASH, verify byte-for-byte golden vectors against the Python V2 client, and cover the POLY_1271 path + unsupported chain behavior.

Modernizes tooling by bumping Go to 1.26, switching to golangci-lint v2 with a new config/formatter setup, updating CI accordingly, and changing the module path/imports to github.com/dkeysil/go-order-utils.

Reviewed by Cursor Bugbot for commit 9f2faa6. Bugbot is set up for automated code reviews on this repo. Configure here.

dkeysil added 6 commits April 18, 2026 10:10
Rewrites the order struct, EIP-712 domain version, and contract addresses to
match CTF Exchange V2 (live 2026-04-22). After cutover V1 signatures will be
rejected by the operator.

Signed struct now has 11 fields: salt, maker, signer, tokenId, makerAmount,
takerAmount, side, signatureType, timestamp, metadata, builder.
ORDER_TYPEHASH = 0xbb86318a2138f5fa8ae32fbe8e659f8fcf13cc6ae4014a707893055433818589.

Removed from OrderData: Taker, Expiration, Nonce, FeeRateBps.
Added to OrderData: Timestamp (unix ms, defaults to now), Metadata (bytes32),
Builder (bytes32 builder code).

Domain version bumped "1" -> "2" (name unchanged).
Exchange addresses replaced with V2 deployments (same on mainnet and Amoy):
  CTFExchange V2        0xE111180000d2663C0091e4f400237545B87B996B
  NegRisk CTFExchange V2 0xe2222d279d744050d28e00520010520000310F59
Collateral updated to pUSD (0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB).
SignatureType enum extended with POLY_1271 (EIP-1271 smart-contract wallets).

Adds utils.GenerateTimestampMs for the default timestamp source and
WithTimestampGenerator for deterministic tests.

Golden signature/hash vectors pinned to py-clob-client-v2==1.0.0 output via
scripts/gen_v2_vectors.py; ORDER_TYPEHASH is independently asserted against
the Solidity constant.
- Drop Bytes32Zero alias; use common.Hash{} directly.
- Public OrderData uses native types: Timestamp int64, Metadata/Builder
  common.Hash. Eliminates string parsing + parseBytes32 helper.
- Order.Side and Order.SignatureType are now uint8 (ABI-native), avoiding
  two per-call big.Int allocations and the Uint64() casts.
- Cache EIP-712 domain separator per (chainId, contract) at constructor
  time. BuildOrderHash becomes a map lookup + one ABI-pack + keccak.
- Drop functional-options for the timestamp generator; plain third
  positional arg (nil for default) is symmetric with saltGenerator.
- Parse Maker once in BuildOrder (was twice when Signer was empty).
- Merge TestBuildOrderHash_V2Goldens + TestBuildSignedOrder_V2Goldens
  into TestV2Goldens with a single table.
- Delete unreachable POLY_GNOSIS_SAFE fallback branch (Maker == Signer
  in all test cases; ValidateSignature never errors).
- Prune migration-era narrating comments; keep WHY-only ones.

All py-clob-client-v2 golden vectors (order hash + signature) still match
byte-for-byte; ORDER_TYPEHASH invariant still holds.
- Pin golangci-lint to v2.11.4 (v1.54.2 from 2023 won't compile under
  modern Go toolchains; pre-existing upstream issue).
- Migrate .golangci.yaml to v2 schema. Configure revive var-naming with
  upperCaseConst: true so SignatureType consts (EOA, POLY_PROXY,
  POLY_GNOSIS_SAFE, POLY_1271) match the TS/Python SDK naming.
- Allow OrderData.TokenId (intentional, mirrors tokenId on the wire).
- Exclude pkg/contracts (auto-generated abigen bindings) from lint.
- Rename package-private _ALL_CAPS vars to camelCase (eip712, builder).
- Rename chainId -> chainID and tokenId -> tokenID in internals.
- Add package doc comments to builder, config, eip712, signer, model,
  utils, and doc comments to every exported type, const, var, func,
  method, and interface.
- Add trailing newlines to two auto-generated contract files to satisfy
  gofmt.
- Replace interface{} with any and fmt.Sprintf+[]byte with fmt.Appendf
  in eip712.

make lint -> 0 issues. make test -> all pass including py-clob-client-v2
golden vectors (byte-for-byte equality preserved across rename).
Two gaps that would break real consumers of the V2 migration:

1. The V1 abigen bindings in pkg/contracts/{exchange,exchange-fees,
   neg-risk,neg-risk-fees} still encode the V1 Order tuple (taker,
   expiration, nonce, feeRateBps) and V1-only methods (IncrementNonce,
   HashOrder, etc.). With config.Exchange / NegRiskExchange now pointing
   at the V2 deployments, any caller that combined GetContracts with
   those bindings would send malformed calldata to the V2 contracts.
   Remove the bindings; nothing in go-order-utils itself imports them,
   and consumers needing V2 bindings should regenerate from the V2 ABI.

2. SignatureType POLY_1271 was exposed by the enum but BuildSignedOrder
   always ran signer.ValidateSignature (ecrecover) against order.Signer,
   which for the real 1271 case (Signer is a smart contract) can never
   match the recovered EOA — every 1271 attempt returned "signature
   error". Skip the local check for POLY_1271; the signature is
   validated on-chain via isValidSignature at fill time. Matches
   py-clob-client-v2 / clob-client-v2 behavior (neither locally verifies).

Adds a POLY_1271 golden vector from py-clob-client-v2 and a regression
test (TestBuildSignedOrder_POLY1271SkipsEcrecover) that exercises the
Signer != EOA path which errored pre-fix.

Drops pkg/contracts exclusion from .golangci.yaml and runs go mod tidy
to remove the now-unused transitive dependencies.
The Makefile pins golangci-lint v2.11.4 which requires Go >= 1.25 at
install time (go install builds from source). The workflow was pinned
to Go 1.21 (EOL since 2024-08) and would fail the lint step with
"module requires Go 1.25.0 or later". Bumping CI to 1.25; the module's
own go directive stays at 1.21 so library consumers aren't forced to
upgrade.
- Regenerate pkg/contracts/exchange with abigen from the V2 CTFExchange
  ABI (source: @polymarket/clob-client-v2 src/order-utils/abi/ExchangeV2.ts).
  The same bytecode is deployed at both V2 addresses (standard 0xE111...
  and neg-risk 0xe222...), so a single binding package covers both —
  pass the address returned by config.GetContracts(...).Exchange or
  .NegRiskExchange.

  The generated Order struct matches the V2 signed layout exactly
  (salt, maker, signer, tokenId, makerAmount, takerAmount, side,
  signatureType, timestamp, metadata, builder, signature).

  V2 has no standalone fee-module contract — fees are operator-set at
  match time — so the V1 *-fees packages are not regenerated.

- Bump module go directive to 1.26 (latest stable) and CI GO_VERSION
  to '1.26'. The previous 1.21 pin was EOL since 2024-08 and blocked
  installing the modern golangci-lint.
@dkeysil
dkeysil requested a review from a team as a code owner April 18, 2026 13:25
dkeysil added 2 commits April 20, 2026 20:56
Renames the fork's module path so bg-prediction-market-api can pull it
alongside the upstream V1 release (github.com/polymarket/go-order-utils
v1.22.6) during the pre-cutover dual-stack window. All internal imports
updated to the new path; no behavioral changes.
@rchernobelskiy

Copy link
Copy Markdown

Thanks for putting together this PR @dkeysil, any idea when it'll be ready to merge?

@dkeysil

dkeysil commented Apr 28, 2026

Copy link
Copy Markdown
Author

@rchernobelskiy I don't work for Polymarket but we are using this fork in our product, so I can assure that it's working in our case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants