Skip to content

createApiKey() / create_or_derive_api_key() doesn't EIP-1271-wrap L1 auth for POLY_1271 deposit wallets — orders rejected with signer != api_key #66

@JustMeJr

Description

@JustMeJr

Affected packages:

@polymarket/clob-client-v2 (verified 1.0.5)
py-clob-client-v2 (latest as of 2026-05-08)

NOT affected: polymarket-client-sdk-v2 (Rust) — see below; it exposes the correct flow.
Reproducible against: https://clob.polymarket.com (Polygon mainnet, V2 exchange)

Summary
For Polymarket V2 deposit wallets (signatureType: 3 / POLY_1271), the TypeScript and Python SDKs have an asymmetry that makes programmatic order placement impossible from the published SDKs:

Order signing correctly wraps the EIP-712 order signature via TypedDataSign so the recovered signer is the deposit wallet (verifying via the wallet's ERC-1271 isValidSignature). Order construction is correct.
API-key registration (createApiKey() / deriveApiKey() / createOrDeriveApiKey() in TS, create_or_derive_api_key() in Python) does not apply the same wrapping. The L1 auth headers are signed by the EOA only, so the api-key is registered against the EOA's address — never the deposit wallet.

The CLOB then enforces order.signer == api_key.address on every order. For POLY_1271 the SDK sends order.signer = funderAddress (deposit wallet) while the api-key is bound to the EOA. The two values can never match, and every order is rejected with:
HTTP 400 {"error":"the order signer address has to be the address of the API KEY"}
The Rust SDK does NOT have this gap — its Client::authentication_builder().funder(...).signature_type(...).authenticate() chain threads funder and signature_type through to the L1 auth, producing creds bound to the deposit wallet. Only the Rust SDK in the official set works for deposit-wallet trading today.
The result: deposit-wallet users (every Magic Link / email-signup account post-April 28, 2026) using the TS or Python SDK cannot place orders, even when configured exactly as the docs at https://docs.polymarket.com/trading/deposit-wallets describe. The docs themselves implicitly assume creds already exist (read from env vars) and never document how to obtain them for a deposit wallet — masking the bug.

Reproduction (TypeScript)
tsimport { ClobClient, Chain, SignatureTypeV2, Side, OrderType } from "@polymarket/clob-client-v2";
import { ethers } from "ethers";

const wallet = new ethers.Wallet(process.env.PRIVATE_KEY!);

// Configured exactly as the docs prescribe:
const client = new ClobClient({
host: "https://clob.polymarket.com",
chain: Chain.POLYGON,
signer: wallet,
signatureType: SignatureTypeV2.POLY_1271, // = 3
funderAddress: "0xDeposit…", // EIP-7702 deposit wallet
});

const creds = await client.createOrDeriveApiKey(); // <-- silently binds to EOA
// any subsequent createAndPostOrder() returns:
// 400 {"error":"the order signer address has to be the address of the API KEY"}
Reproduction (Python — same bug)
pythonfrom py_clob_client_v2 import ClobClient, SignatureTypeV2

clob = ClobClient(
host="https://clob.polymarket.com",
chain_id=137,
key=PRIVATE_KEY, # EOA private key
signature_type=SignatureTypeV2.POLY_1271, # = 3
funder=DEPOSIT_WALLET,
)
creds = clob.create_or_derive_api_key() # binds to EOA, not the funder

clob.create_and_post_order(...) -> same 400 as above

The api-key returned in both languages is registered against the EOA, regardless of the signature_type and funder set on the client. Confirmed empirically — when the SDK builds the order with order.signer = funder (per POLY_1271 spec) and POSTs it, the CLOB rejects with the error above.

Rust SDK shows the correct API surface
From the docs page at https://docs.polymarket.com/trading/deposit-wallets (Rust tab), Polymarket's own Rust SDK does it right:
rustlet client = Client::new(&host, Config::default())?
.authentication_builder(&signer)
.funder(deposit_wallet)
.signature_type(SignatureType::Poly1271)
.authenticate() // <-- the missing piece
.await?;
This is the API surface the TS and Python SDKs need: an authentication step that takes funder + signature_type and produces creds bound to the funder. Today, the TS/Python createApiKey / create_or_derive_api_key methods take neither.

Root cause in dist/index.cjs (TypeScript SDK)
createApiKey calls createL1Headers without passing the funder address and without doing any contract-wallet wrapping:
js// createApiKey, around line 1670
async createApiKey(nonce) {
this.canL1Auth();
const endpoint = ${this.host}${CREATE_API_KEY};
const headers = await createL1Headers(
this.signer,
this.chainId,
nonce,
this.useServerTime ? await this.getServerTime() : void 0
// <-- 5th arg address not passed; no signatureType branch
);
return await this.post(endpoint, { headers })...
}
createL1Headers accepts an optional address 5th parameter, but produces a plain ECDSA signature regardless — no EIP-1271 wrapping (around line 306):
jsvar createL1Headers = async (signer, chainId, nonce, timestamp, address) => {
const ts = (timestamp ?? Math.floor(Date.now() / 1000)).toString();
const n = (nonce ?? 0).toString();
const sig = await buildClobEip712Signature(signer, chainId, ts, n, address);
const resolvedAddress = address ?? await getSignerAddress(signer);
return { POLY_ADDRESS: resolvedAddress, POLY_SIGNATURE: sig, POLY_TIMESTAMP: ts, POLY_NONCE: n };
};
Manually passing address: depositWallet produces headers where POLY_ADDRESS != ecrecover(POLY_SIGNATURE), and the CLOB rejects with "Invalid L1 Request headers" — confirming the CLOB does plain ECDSA recovery on this endpoint. So there is no SDK-supported way to register an api-key against a smart-contract wallet.
By contrast, buildOrderSignature for signatureType === 3 does the right thing — wraps via TypedDataSign with verifyingContract: msg.signer so the deposit wallet's ERC-1271 implementation can validate it (around line 875–945):
jsasync buildOrderSignature(typedData) {
delete typedData.types.EIP712Domain;
const msg = typedData.message;
if (msg.signatureType !== 3 /* POLY_1271 */) {
return signTypedDataWithSigner({ signer: this.signer, ... });
}
// ERC-7739 / TypedDataSign wrapping for POLY_1271
const innerSig = await signTypedDataWithSigner({
signer: this.signer,
domain: { name: ..., version: ..., chainId: this.chainId, verifyingContract: this.contractAddress },
types: { TypedDataSign: TYPED_DATA_SIGN_STRUCT, Order: CTF_EXCHANGE_V2_ORDER_STRUCT },
primaryType: "TypedDataSign",
value: { contents: typedData.message, name: "DepositWallet", version: "1",
chainId: this.chainId, verifyingContract: msg.signer, salt: bytes32Zero }
});
// ... 7739 trailer
}
So the TS SDK knows how to produce ERC-1271-wrapped signatures for POLY_1271 — it just doesn't do it for L1 auth. That's the gap.
The Python SDK (py-clob-client-v2) has the exact same shape — create_or_derive_api_key() returns successfully but binds the resulting api-key to the EOA, regardless of the signature_type / funder the client was constructed with.

Expected behavior
When the SDK is constructed with signatureType: POLY_1271 + funderAddress: depositWallet, calls to createApiKey() / deriveApiKey() / createOrDeriveApiKey() (and Python create_or_derive_api_key()) should:

Sign the L1 ClobAuth message with the EOA.
Wrap that signature via TypedDataSign (the same ERC-7739 / EIP-1271 wrapping buildOrderSignature already implements) so the deposit wallet validates it via isValidSignature.
Set POLY_ADDRESS = funderAddress (the deposit wallet) in the request headers.

Then the registered api-key is bound to the deposit wallet, order.signer == api_key.address holds for POLY_1271 orders, and orders post successfully — exactly as the Rust SDK does today.
The same change is needed in createL2Headers for L2 auth; otherwise reads against api-keys registered to deposit wallets break the same way.

Suggested fix
Inside createApiKey (and deriveApiKey, createL2Headers):
jsconst isContractWallet = this.signatureType === SignatureTypeV2.POLY_1271
|| this.signatureType === SignatureTypeV2.POLY_GNOSIS_SAFE;

const headers = isContractWallet
? await createL1HeadersWrapped1271(
this.signer, this.chainId, nonce, ts, this.funderAddress
)
: await createL1Headers(this.signer, this.chainId, nonce, ts);
…where createL1HeadersWrapped1271 reuses the existing TypedDataSign wrapping logic from buildOrderSignature, but signs the L1 ClobAuth typed data instead of an Order. Equivalent change in the Python SDK.
Happy to send a PR if a maintainer can confirm the desired wrapper format for the L1 endpoint (or just port the Rust SDK's authentication_builder flow over).

Related observation
A third-party SDK, polynode (https://polynode.mintlify.app/guides/deposit-wallets), explicitly markets itself as the workaround for this exact problem — they handle deposit-wallet auth transparently and charge for it. Their own docs verbatim: "if you use the polynode SDK, all of this is handled automatically. ensureReady() detects the wallet type, order() signs correctly", and for direct CLOB users: "If you call the Polymarket CLOB directly (without our SDK): you need to implement ERC-7739 TypedDataSign wrapping for signatureType: 3 orders". Their existence is itself evidence that the gap is widely known.

Workarounds users are forced into today

Manual mirroring via the UI: read trade signals from a separate dashboard, click into Polymarket, place each order by hand. This is what we've fallen back to.
UI-generated api-keys at polymarket.com/settings?tab=api-keys: these fail the same signer != api_key check from a slightly different direction (the order's signer field is set by the SDK to the deposit wallet, while the UI key is typically bound to the proxy or EOA).
Switch to the Rust SDK: works, but rewrites a non-trivial amount of trading-bot code in another language.
Switch to the third-party polynode SDK: works (per their docs), but is a paid managed service (pn_live_… keys) — not viable for small-budget retail users.

None of these scale for programmatic copy-trading from JavaScript or Python — which is the use case the V2 SDKs exist to support.

Environment

@polymarket/clob-client-v2: 1.0.5
py-clob-client-v2: latest (2026-05-08)
Node: v24.14.1, Python 3.14.3
Chain: Polygon mainnet (137)
Wallet bytecode: 0xef0100… (EIP-7702 delegated; consistent with V2 deposit wallets per Polymarket docs)
Date: 2026-05-08

Tagged: bug, v2, auth, deposit-wallets

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions