diff --git a/src/polymarket/__init__.py b/src/polymarket/__init__.py index 75b19cb..884d736 100644 --- a/src/polymarket/__init__.py +++ b/src/polymarket/__init__.py @@ -23,6 +23,7 @@ ApiKeyCreds, AssetType, BalanceAllowance, + BuilderApiKeyInfo, BuilderFeeRates, BuilderTrade, BuilderVolumeEntry, @@ -177,6 +178,7 @@ "AsyncSecureClient", "BalanceAllowance", "BuilderApiKey", + "BuilderApiKeyInfo", "BuilderFeeRates", "BuilderTrade", "BuilderVolumeEntry", diff --git a/src/polymarket/_internal/actions/auth.py b/src/polymarket/_internal/actions/auth.py index 59a1bfc..f5a883c 100644 --- a/src/polymarket/_internal/actions/auth.py +++ b/src/polymarket/_internal/actions/auth.py @@ -2,12 +2,17 @@ from pydantic import TypeAdapter, ValidationError +from polymarket._internal.actions.relayer.auth import build_builder_key_headers from polymarket._internal.l1_auth import ApiKeyAuthSignature +from polymarket.auth import BuilderApiKey from polymarket.clients._transport import AsyncTransport, SyncTransport from polymarket.errors import RequestRejectedError, UnexpectedResponseError -from polymarket.models.clob import ApiKeyCreds +from polymarket.models.clob import ApiKeyCreds, BuilderApiKeyInfo + +_BUILDER_API_KEY_PATH = "/auth/builder-api-key" _ApiKeysListAdapter = TypeAdapter(tuple[str, ...]) +_BuilderApiKeyInfoListAdapter = TypeAdapter(tuple[BuilderApiKeyInfo, ...]) def build_l1_auth_headers(signature: ApiKeyAuthSignature) -> dict[str, str]: @@ -33,6 +38,32 @@ def parse_api_keys_response(data: object) -> tuple[str, ...]: raise UnexpectedResponseError("api keys response did not match expected shape") from error +def parse_builder_api_key_creds(data: object) -> BuilderApiKey: + if not isinstance(data, dict): + raise UnexpectedResponseError("builder api key response did not match expected shape") + fields = cast(dict[str, object], data) + key = fields.get("key") + secret = fields.get("secret") + passphrase = fields.get("passphrase") + if not (isinstance(key, str) and isinstance(secret, str) and isinstance(passphrase, str)): + raise UnexpectedResponseError("builder api key response did not match expected shape") + return BuilderApiKey(key=key, secret=secret, passphrase=passphrase) + + +def parse_builder_api_keys_response(data: object) -> tuple[BuilderApiKeyInfo, ...]: + if not isinstance(data, list): + raise UnexpectedResponseError("builder api keys response did not match expected shape") + normalized = [ + {"key": item} if isinstance(item, str) else item for item in cast(list[object], data) + ] + try: + return _BuilderApiKeyInfoListAdapter.validate_python(normalized) + except ValidationError as error: + raise UnexpectedResponseError( + "builder api keys response did not match expected shape" + ) from error + + async def create_api_key(clob: AsyncTransport, signature: ApiKeyAuthSignature) -> ApiKeyCreds: payload = await clob.post_json("/auth/api-key", headers=build_l1_auth_headers(signature)) return parse_api_key_creds(payload) @@ -67,6 +98,27 @@ async def delete_api_key(secure_clob: AsyncTransport) -> None: ) +async def create_builder_api_key(secure_clob: AsyncTransport) -> BuilderApiKey: + payload = await secure_clob.post_json(_BUILDER_API_KEY_PATH) + return parse_builder_api_key_creds(payload) + + +async def fetch_builder_api_keys(secure_clob: AsyncTransport) -> tuple[BuilderApiKeyInfo, ...]: + payload = await secure_clob.get_json(_BUILDER_API_KEY_PATH) + return parse_builder_api_keys_response(payload) + + +async def revoke_builder_api_key(clob: AsyncTransport, builder_key: BuilderApiKey) -> None: + headers = build_builder_key_headers( + creds=builder_key, method="DELETE", path=_BUILDER_API_KEY_PATH + ) + payload = await clob.delete_json(_BUILDER_API_KEY_PATH, headers=headers) + if payload != "OK": + raise UnexpectedResponseError( + f"revoke builder api key response did not match expected shape: {payload!r}" + ) + + def create_api_key_sync(clob: SyncTransport, signature: ApiKeyAuthSignature) -> ApiKeyCreds: payload = clob.post_json("/auth/api-key", headers=build_l1_auth_headers(signature)) return parse_api_key_creds(payload) @@ -101,10 +153,33 @@ def delete_api_key_sync(secure_clob: SyncTransport) -> None: ) +def create_builder_api_key_sync(secure_clob: SyncTransport) -> BuilderApiKey: + payload = secure_clob.post_json(_BUILDER_API_KEY_PATH) + return parse_builder_api_key_creds(payload) + + +def fetch_builder_api_keys_sync(secure_clob: SyncTransport) -> tuple[BuilderApiKeyInfo, ...]: + payload = secure_clob.get_json(_BUILDER_API_KEY_PATH) + return parse_builder_api_keys_response(payload) + + +def revoke_builder_api_key_sync(clob: SyncTransport, builder_key: BuilderApiKey) -> None: + headers = build_builder_key_headers( + creds=builder_key, method="DELETE", path=_BUILDER_API_KEY_PATH + ) + payload = clob.delete_json(_BUILDER_API_KEY_PATH, headers=headers) + if payload != "OK": + raise UnexpectedResponseError( + f"revoke builder api key response did not match expected shape: {payload!r}" + ) + + __all__ = [ "build_l1_auth_headers", "create_api_key", "create_api_key_sync", + "create_builder_api_key", + "create_builder_api_key_sync", "create_or_derive_api_key", "create_or_derive_api_key_sync", "delete_api_key", @@ -113,6 +188,12 @@ def delete_api_key_sync(secure_clob: SyncTransport) -> None: "derive_api_key_sync", "fetch_api_keys", "fetch_api_keys_sync", + "fetch_builder_api_keys", + "fetch_builder_api_keys_sync", "parse_api_key_creds", "parse_api_keys_response", + "parse_builder_api_key_creds", + "parse_builder_api_keys_response", + "revoke_builder_api_key", + "revoke_builder_api_key_sync", ] diff --git a/src/polymarket/_internal/actions/relayer/auth.py b/src/polymarket/_internal/actions/relayer/auth.py index f1b4b43..2deaa16 100644 --- a/src/polymarket/_internal/actions/relayer/auth.py +++ b/src/polymarket/_internal/actions/relayer/auth.py @@ -11,25 +11,39 @@ SyncRelayerHeaderResolver = Callable[[str, str, str | None], Mapping[str, str]] +# Single source of truth for builder-key (POLY_BUILDER_*) auth-header construction. Shared by the +# relayer header resolvers (below) and the builder-api-key revoke path so the HMAC signing never +# drifts between copies — keep all builder-key header building here rather than inlining it. +def build_builder_key_headers( + *, creds: BuilderApiKey, method: str, path: str, body: str | None = None +) -> dict[str, str]: + """Build the ``POLY_BUILDER_*`` headers that authenticate a request as a builder key. + + Signs ``timestamp + method + path (+ body)`` with the builder key's secret. Used both by + the relayer header resolver and by the builder-api-key revoke path. + """ + timestamp = int(time.time()) + signature = build_hmac_signature( + secret=creds.secret, + timestamp=timestamp, + method=method, + path=path, + body=body, + ) + return { + "POLY_BUILDER_API_KEY": creds.key, + "POLY_BUILDER_PASSPHRASE": creds.passphrase, + "POLY_BUILDER_SIGNATURE": signature, + "POLY_BUILDER_TIMESTAMP": str(timestamp), + } + + def make_relayer_header_resolver(api_key: ApiKey) -> RelayerHeaderResolver: if isinstance(api_key, BuilderApiKey): creds = api_key async def builder_resolver(method: str, path: str, body: str | None) -> Mapping[str, str]: - timestamp = int(time.time()) - signature = build_hmac_signature( - secret=creds.secret, - timestamp=timestamp, - method=method, - path=path, - body=body, - ) - return { - "POLY_BUILDER_API_KEY": creds.key, - "POLY_BUILDER_PASSPHRASE": creds.passphrase, - "POLY_BUILDER_SIGNATURE": signature, - "POLY_BUILDER_TIMESTAMP": str(timestamp), - } + return build_builder_key_headers(creds=creds, method=method, path=path, body=body) return builder_resolver @@ -52,20 +66,7 @@ def make_relayer_header_resolver_sync(api_key: ApiKey) -> SyncRelayerHeaderResol creds = api_key def builder_resolver(method: str, path: str, body: str | None) -> Mapping[str, str]: - timestamp = int(time.time()) - signature = build_hmac_signature( - secret=creds.secret, - timestamp=timestamp, - method=method, - path=path, - body=body, - ) - return { - "POLY_BUILDER_API_KEY": creds.key, - "POLY_BUILDER_PASSPHRASE": creds.passphrase, - "POLY_BUILDER_SIGNATURE": signature, - "POLY_BUILDER_TIMESTAMP": str(timestamp), - } + return build_builder_key_headers(creds=creds, method=method, path=path, body=body) return builder_resolver @@ -86,6 +87,7 @@ def relayer_resolver(method: str, path: str, body: str | None) -> Mapping[str, s __all__ = [ "RelayerHeaderResolver", "SyncRelayerHeaderResolver", + "build_builder_key_headers", "make_relayer_header_resolver", "make_relayer_header_resolver_sync", ] diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index 7bcfe77..f706b71 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -128,7 +128,7 @@ derive_current_deposit_wallet_address, signature_type_for, ) -from polymarket.auth import ApiKey +from polymarket.auth import ApiKey, BuilderApiKey from polymarket.clients._transport import AsyncTransport from polymarket.clients.async_public import AsyncPublicClient from polymarket.environments import PRODUCTION, Environment @@ -168,6 +168,7 @@ TagReference, Team, ) +from polymarket.models.clob.api_key import BuilderApiKeyInfo from polymarket.models.clob.cancel import CancelOrdersResponse from polymarket.models.clob.market_events import MarketEvent from polymarket.models.clob.order_response import OrderResponse @@ -1599,6 +1600,28 @@ async def delete_api_key(self) -> None: """Delete the API key currently used by this client.""" await _auth_actions.delete_api_key(self._ctx.secure_clob) + async def create_builder_api_key(self) -> BuilderApiKey: + """Create a new builder API key for the authenticated account.""" + return await _auth_actions.create_builder_api_key(self._ctx.secure_clob) + + async def fetch_builder_api_keys(self) -> tuple[BuilderApiKeyInfo, ...]: + """List the builder API keys for the authenticated account.""" + return await _auth_actions.fetch_builder_api_keys(self._ctx.secure_clob) + + async def revoke_builder_api_key(self) -> None: + """Revoke the builder API key this client is configured with. + + The revocation is authenticated by the builder key itself, so the client must have been + created with the key to revoke (``AsyncSecureClient.create(api_key=BuilderApiKey(...))``). + """ + builder_key = self._ctx.api_key + if not isinstance(builder_key, BuilderApiKey): + raise UserInputError( + "revoke_builder_api_key requires a client created with the builder key to " + "revoke (pass api_key=BuilderApiKey(...) to AsyncSecureClient.create)." + ) + await _auth_actions.revoke_builder_api_key(self._ctx.clob, builder_key) + async def end_authentication(self) -> "AsyncPublicClient": """Delete current credentials, close this client, and return an async public client.""" environment = self._ctx.environment diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index 32c0808..53f6ea7 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -116,7 +116,7 @@ derive_current_deposit_wallet_address_sync, signature_type_for, ) -from polymarket.auth import ApiKey +from polymarket.auth import ApiKey, BuilderApiKey from polymarket.clients._transport import SyncHeaderResolver, SyncTransport from polymarket.environments import PRODUCTION, Environment from polymarket.errors import ( @@ -153,7 +153,7 @@ TagReference, Team, ) -from polymarket.models.clob import BuilderTrade +from polymarket.models.clob import BuilderApiKeyInfo, BuilderTrade from polymarket.models.clob.cancel import CancelOrdersResponse from polymarket.models.clob.order_response import OrderResponse from polymarket.models.clob.orders import MarketOrderType, SignedOrder @@ -1405,6 +1405,28 @@ def delete_api_key(self) -> None: """Delete the API key currently used by this client.""" _auth_actions.delete_api_key_sync(self._ctx.secure_clob) + def create_builder_api_key(self) -> BuilderApiKey: + """Create a new builder API key for the authenticated account.""" + return _auth_actions.create_builder_api_key_sync(self._ctx.secure_clob) + + def fetch_builder_api_keys(self) -> tuple[BuilderApiKeyInfo, ...]: + """List the builder API keys for the authenticated account.""" + return _auth_actions.fetch_builder_api_keys_sync(self._ctx.secure_clob) + + def revoke_builder_api_key(self) -> None: + """Revoke the builder API key this client is configured with. + + The revocation is authenticated by the builder key itself, so the client must have been + created with the key to revoke (``SecureClient.create(api_key=BuilderApiKey(...))``). + """ + builder_key = self._ctx.api_key + if not isinstance(builder_key, BuilderApiKey): + raise UserInputError( + "revoke_builder_api_key requires a client created with the builder key to " + "revoke (pass api_key=BuilderApiKey(...) to SecureClient.create)." + ) + _auth_actions.revoke_builder_api_key_sync(self._ctx.clob, builder_key) + def end_authentication(self) -> "PublicClient": """Delete current credentials, close this client, and return a public client.""" from polymarket.clients.public import PublicClient diff --git a/src/polymarket/models/__init__.py b/src/polymarket/models/__init__.py index 96f1b2d..b9a3dad 100644 --- a/src/polymarket/models/__init__.py +++ b/src/polymarket/models/__init__.py @@ -3,6 +3,7 @@ ApiKeyCreds, AssetType, BalanceAllowance, + BuilderApiKeyInfo, BuilderFeeRates, BuilderTrade, CancelOrdersResponse, @@ -122,6 +123,7 @@ "ApiKeyCreds", "AssetType", "BalanceAllowance", + "BuilderApiKeyInfo", "BuilderFeeRates", "BuilderTrade", "CancelOrdersResponse", diff --git a/src/polymarket/models/clob/__init__.py b/src/polymarket/models/clob/__init__.py index edab5bc..a253704 100644 --- a/src/polymarket/models/clob/__init__.py +++ b/src/polymarket/models/clob/__init__.py @@ -6,7 +6,7 @@ Notification, OpenOrder, ) -from polymarket.models.clob.api_key import ApiKeyCreds +from polymarket.models.clob.api_key import ApiKeyCreds, BuilderApiKeyInfo from polymarket.models.clob.builder import BuilderFeeRates, BuilderTrade from polymarket.models.clob.cancel import CancelOrdersResponse from polymarket.models.clob.last_trade import LastTradePrice, LastTradePriceForToken @@ -45,6 +45,7 @@ "ApiKeyCreds", "AssetType", "BalanceAllowance", + "BuilderApiKeyInfo", "BuilderFeeRates", "BuilderTrade", "CancelOrdersResponse", diff --git a/src/polymarket/models/clob/api_key.py b/src/polymarket/models/clob/api_key.py index 9ea7708..75bed6f 100644 --- a/src/polymarket/models/clob/api_key.py +++ b/src/polymarket/models/clob/api_key.py @@ -3,6 +3,7 @@ from pydantic import Field from polymarket.models.base import BaseModel +from polymarket.models.clob._validators import EpochMsOrIsoTimestamp class ApiKeyCreds(BaseModel): @@ -22,4 +23,16 @@ def _repr_html_(self) -> str: ) -__all__ = ["ApiKeyCreds"] +class BuilderApiKeyInfo(BaseModel): + """A builder API key as listed for an account — identity and lifecycle, no secret. + + Returned by ``fetch_builder_api_keys``. ``revoked_at`` is ``None`` while the key is + active and set to the revocation time once revoked. + """ + + key: str + created_at: EpochMsOrIsoTimestamp = Field(default=None, validation_alias="createdAt") + revoked_at: EpochMsOrIsoTimestamp = Field(default=None, validation_alias="revokedAt") + + +__all__ = ["ApiKeyCreds", "BuilderApiKeyInfo"] diff --git a/tests/unit/test_auth_actions.py b/tests/unit/test_auth_actions.py index 2e050a9..a7478a4 100644 --- a/tests/unit/test_auth_actions.py +++ b/tests/unit/test_auth_actions.py @@ -4,8 +4,11 @@ build_l1_auth_headers, parse_api_key_creds, parse_api_keys_response, + parse_builder_api_key_creds, + parse_builder_api_keys_response, ) from polymarket._internal.l1_auth import ApiKeyAuthSignature +from polymarket.auth import BuilderApiKey from polymarket.errors import UnexpectedResponseError @@ -60,3 +63,46 @@ def test_parse_api_keys_response_rejects_missing_field() -> None: def test_parse_api_keys_response_rejects_non_string_entry() -> None: with pytest.raises(UnexpectedResponseError): parse_api_keys_response({"apiKeys": [1, 2]}) + + +def test_parse_builder_api_key_creds_returns_builder_api_key() -> None: + creds = parse_builder_api_key_creds({"key": "k", "secret": "s", "passphrase": "p"}) + + assert isinstance(creds, BuilderApiKey) + assert (creds.key, creds.secret, creds.passphrase) == ("k", "s", "p") + + +def test_parse_builder_api_key_creds_rejects_missing_field() -> None: + with pytest.raises(UnexpectedResponseError): + parse_builder_api_key_creds({"key": "k", "secret": "s"}) + + +def test_parse_builder_api_key_creds_rejects_non_dict() -> None: + with pytest.raises(UnexpectedResponseError): + parse_builder_api_key_creds([]) + + +def test_parse_builder_api_keys_response_parses_records() -> None: + keys = parse_builder_api_keys_response( + [{"key": "k", "createdAt": "1700000000000", "revokedAt": None}] + ) + + assert len(keys) == 1 + assert keys[0].key == "k" + assert keys[0].created_at is not None + assert keys[0].revoked_at is None + + +def test_parse_builder_api_keys_response_normalizes_bare_string_elements() -> None: + keys = parse_builder_api_keys_response(["a", {"key": "b"}]) + + assert [k.key for k in keys] == ["a", "b"] + + +def test_parse_builder_api_keys_response_accepts_empty_list() -> None: + assert parse_builder_api_keys_response([]) == () + + +def test_parse_builder_api_keys_response_rejects_non_list() -> None: + with pytest.raises(UnexpectedResponseError): + parse_builder_api_keys_response({"apiKeys": []}) diff --git a/tests/unit/test_secure_builder_api_keys_sync.py b/tests/unit/test_secure_builder_api_keys_sync.py new file mode 100644 index 0000000..e529ed5 --- /dev/null +++ b/tests/unit/test_secure_builder_api_keys_sync.py @@ -0,0 +1,126 @@ +# pyright: reportPrivateUsage=false +import dataclasses +from typing import Any +from urllib.parse import urlparse + +import httpx +import pytest + +from polymarket import ApiKeyCreds, BuilderApiKey, SecureClient +from polymarket.clients._transport import SyncTransport +from polymarket.errors import UnexpectedResponseError, UserInputError + +PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" +SIGNER_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" +FAKE_CREDS = ApiKeyCreds(key="test-key", passphrase="test-passphrase", secret="dGVzdA==") +BUILDER = BuilderApiKey(key="bk", secret="dGVzdA==", passphrase="bp") + + +def _capture(captured: list[httpx.Request], status: int, payload: Any) -> httpx.MockTransport: + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(status, json=payload, request=request) + + return httpx.MockTransport(handler) + + +def _install_secure_clob(client: SecureClient, handler: httpx.MockTransport) -> None: + transport = SyncTransport( + base_url="https://clob.test", + client=httpx.Client(base_url="https://clob.test", transport=handler), + header_resolver=client._ctx.secure_clob._header_resolver, + ) + client._ctx = dataclasses.replace(client._ctx, secure_clob=transport) + + +def _install_clob(client: SecureClient, handler: httpx.MockTransport) -> None: + transport = SyncTransport( + base_url="https://clob.test", + client=httpx.Client(base_url="https://clob.test", transport=handler), + header_resolver=client._ctx.clob._header_resolver, + ) + client._ctx = dataclasses.replace(client._ctx, clob=transport) + + +def _make_client(*, with_builder_key: bool = False) -> SecureClient: + return SecureClient._create( + private_key=PRIVATE_KEY, + wallet=SIGNER_ADDRESS, + credentials=FAKE_CREDS, + api_key=BUILDER if with_builder_key else None, + validate_credentials=False, + ) + + +def test_create_builder_api_key_sends_l2_and_returns_credential() -> None: + captured: list[httpx.Request] = [] + payload = {"key": "new-bk", "secret": "new-secret", "passphrase": "new-pass"} + + with _make_client() as client: + _install_secure_clob(client, _capture(captured, 200, payload)) + result = client.create_builder_api_key() + + assert isinstance(result, BuilderApiKey) + assert (result.key, result.secret, result.passphrase) == ("new-bk", "new-secret", "new-pass") + request = captured[0] + assert request.method == "POST" + assert urlparse(str(request.url)).path == "/auth/builder-api-key" + # Create authenticates as the account (L2), not as a builder key. + assert request.headers.get("POLY_API_KEY") == FAKE_CREDS.key + assert request.headers.get("POLY_BUILDER_API_KEY") is None + + +def test_fetch_builder_api_keys_returns_records_via_l2() -> None: + captured: list[httpx.Request] = [] + payload = [{"key": "bk", "createdAt": "1700000000000", "revokedAt": None}] + + with _make_client() as client: + _install_secure_clob(client, _capture(captured, 200, payload)) + keys = client.fetch_builder_api_keys() + + assert len(keys) == 1 + assert keys[0].key == "bk" + assert keys[0].created_at is not None + assert keys[0].revoked_at is None + request = captured[0] + assert request.method == "GET" + assert urlparse(str(request.url)).path == "/auth/builder-api-key" + assert request.headers.get("POLY_API_KEY") == FAKE_CREDS.key + + +def test_fetch_builder_api_keys_normalizes_bare_string_elements() -> None: + with _make_client() as client: + _install_secure_clob(client, _capture([], 200, ["bk1", {"key": "bk2"}])) + keys = client.fetch_builder_api_keys() + + assert [k.key for k in keys] == ["bk1", "bk2"] + + +def test_revoke_builder_api_key_authenticates_with_builder_key_not_l2() -> None: + captured: list[httpx.Request] = [] + + with _make_client(with_builder_key=True) as client: + _install_clob(client, _capture(captured, 200, "OK")) + client.revoke_builder_api_key() + + request = captured[0] + assert request.method == "DELETE" + assert urlparse(str(request.url)).path == "/auth/builder-api-key" + # The load-bearing guardrail (ts-sdk#68): revoke is signed by the builder key's own HMAC, + # NOT the account L2 credential. + assert request.headers.get("POLY_BUILDER_API_KEY") == BUILDER.key + assert request.headers.get("POLY_BUILDER_PASSPHRASE") == BUILDER.passphrase + assert request.headers.get("POLY_BUILDER_SIGNATURE") + assert request.headers.get("POLY_BUILDER_TIMESTAMP") + assert request.headers.get("POLY_API_KEY") is None + + +def test_revoke_builder_api_key_requires_a_builder_key() -> None: + with pytest.raises(UserInputError), _make_client() as client: + client.revoke_builder_api_key() + + +def test_revoke_builder_api_key_raises_on_non_ok_payload() -> None: + with pytest.raises(UnexpectedResponseError), _make_client(with_builder_key=True) as client: + _install_clob(client, _capture([], 200, "FAIL")) + client.revoke_builder_api_key()