Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 41 additions & 27 deletions src/polymarket/_internal/actions/relayer/approvals.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,29 @@ async def resolve_missing_trading_approval_calls(
rpc: JsonRpcClient, *, wallet: EvmAddress, environment: Environment
) -> list[TransactionCall]:
erc20, erc1155 = _required_trading_approvals(environment)
erc20_missing: list[TransactionCall] = []
for approval in erc20:
check = erc20_allowance_call(
erc20_checks = [
erc20_allowance_call(
token_address=approval.token_address,
owner=wallet,
spender=approval.spender,
)
allowance = decode_erc20_allowance_result(
await rpc.eth_call(to=str(check.to), data=check.data)
for approval in erc20
]
erc1155_checks = [
erc1155_is_approved_for_all_call(
token_address=approval.token_address,
owner=wallet,
operator=approval.operator,
)
for approval in erc1155
]
results = await rpc.eth_call_batch(
[(str(check.to), check.data) for check in [*erc20_checks, *erc1155_checks]]
)

erc20_missing: list[TransactionCall] = []
for approval, result in zip(erc20, results[: len(erc20)], strict=True):
allowance = decode_erc20_allowance_result(result)
if allowance < approval.amount:
erc20_missing.append(
erc20_approval_call(
Expand All @@ -55,15 +68,8 @@ async def resolve_missing_trading_approval_calls(
)

erc1155_missing: list[TransactionCall] = []
for approval in erc1155:
check = erc1155_is_approved_for_all_call(
token_address=approval.token_address,
owner=wallet,
operator=approval.operator,
)
approved = decode_erc1155_is_approved_for_all_result(
await rpc.eth_call(to=str(check.to), data=check.data)
)
for approval, result in zip(erc1155, results[len(erc20) :], strict=True):
approved = decode_erc1155_is_approved_for_all_result(result)
if not approved:
erc1155_missing.append(
erc1155_set_approval_for_all_call(
Expand All @@ -80,14 +86,29 @@ def resolve_missing_trading_approval_calls_sync(
rpc: SyncJsonRpcClient, *, wallet: EvmAddress, environment: Environment
) -> list[TransactionCall]:
erc20, erc1155 = _required_trading_approvals(environment)
erc20_missing: list[TransactionCall] = []
for approval in erc20:
check = erc20_allowance_call(
erc20_checks = [
erc20_allowance_call(
token_address=approval.token_address,
owner=wallet,
spender=approval.spender,
)
allowance = decode_erc20_allowance_result(rpc.eth_call(to=str(check.to), data=check.data))
for approval in erc20
]
erc1155_checks = [
erc1155_is_approved_for_all_call(
token_address=approval.token_address,
owner=wallet,
operator=approval.operator,
)
for approval in erc1155
]
results = rpc.eth_call_batch(
[(str(check.to), check.data) for check in [*erc20_checks, *erc1155_checks]]
)

erc20_missing: list[TransactionCall] = []
for approval, result in zip(erc20, results[: len(erc20)], strict=True):
allowance = decode_erc20_allowance_result(result)
if allowance < approval.amount:
erc20_missing.append(
erc20_approval_call(
Expand All @@ -98,15 +119,8 @@ def resolve_missing_trading_approval_calls_sync(
)

erc1155_missing: list[TransactionCall] = []
for approval in erc1155:
check = erc1155_is_approved_for_all_call(
token_address=approval.token_address,
owner=wallet,
operator=approval.operator,
)
approved = decode_erc1155_is_approved_for_all_result(
rpc.eth_call(to=str(check.to), data=check.data)
)
for approval, result in zip(erc1155, results[len(erc20) :], strict=True):
approved = decode_erc1155_is_approved_for_all_result(result)
if not approved:
erc1155_missing.append(
erc1155_set_approval_for_all_call(
Expand Down
165 changes: 138 additions & 27 deletions src/polymarket/_internal/eoa/rpc.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
from __future__ import annotations

import asyncio
import json as _json
from typing import Any, cast
from collections.abc import Sequence
from typing import Any, TypeAlias, cast

from polymarket.clients._transport import AsyncTransport, SyncTransport
from polymarket.errors import RequestRejectedError, UnexpectedResponseError, UserInputError

_JSON_RPC_REVERT_CODES = frozenset({3, -32_000, -32_003, -32_015, -32_603})
_JSON_RPC_REVERT_TOKENS = ("execution reverted", "revert", "invalid opcode")
EthCallBatchRequest: TypeAlias = tuple[str, str]


class JsonRpcCallError(RequestRejectedError):
Expand Down Expand Up @@ -65,32 +68,63 @@ async def verify_chain_id(self, expected: int) -> None:
self._verified_chain_id = actual

async def _call(self, method: str, params: list[Any]) -> Any:
envelope = self._build_envelope(method, params)
raw = await self._transport.post_json("", json=envelope)
return _parse_rpc_response(method, raw)

def _build_envelope(self, method: str, params: list[Any]) -> dict[str, Any]:
self._id += 1
envelope = {
return {
"jsonrpc": "2.0",
"id": self._id,
"method": method,
"params": params,
}
raw = await self._transport.post_json("", json=envelope)
if not isinstance(raw, dict):
raise UnexpectedResponseError(f"JSON-RPC {method} returned a non-object response")
response = cast(dict[str, Any], raw)
if "error" in response:
err: Any = response["error"]
code, message, data = _extract_error_fields(err)
raise JsonRpcCallError(method=method, code=code, message=message, data=data)
return response.get("result")

async def eth_chain_id(self) -> int:
result = await self._call("eth_chainId", [])
return _hex_to_int(result, "eth_chainId")

async def eth_call(self, *, to: str, data: str, block: str = "latest") -> str:
result = await self._call("eth_call", [{"to": to, "data": data}, block])
if not isinstance(result, str) or not _is_rpc_hex_string(result):
raise UnexpectedResponseError("eth_call did not return a hex string")
return result
return _parse_eth_call_result(result)

async def eth_call_batch(
self, requests: Sequence[EthCallBatchRequest], *, block: str = "latest"
) -> list[str]:
if not requests:
return []
return await self._eth_call_batch_with_split(requests, block=block)

async def _eth_call_batch_with_split(
self, requests: Sequence[EthCallBatchRequest], *, block: str
) -> list[str]:
if len(requests) == 1:
to, data = requests[0]
return [await self.eth_call(to=to, data=data, block=block)]

try:
return await self._post_eth_call_batch(requests, block=block)
except RequestRejectedError as error:
if error.status < 500:
raise

midpoint = (len(requests) + 1) // 2
left, right = await asyncio.gather(
self._eth_call_batch_with_split(requests[:midpoint], block=block),
self._eth_call_batch_with_split(requests[midpoint:], block=block),
)
return [*left, *right]

async def _post_eth_call_batch(
self, requests: Sequence[EthCallBatchRequest], *, block: str
) -> list[str]:
envelopes = [
self._build_envelope("eth_call", [{"to": to, "data": data}, block])
for to, data in requests
]
raw = await self._transport.post_json("", json=envelopes)
return _parse_eth_call_batch_response(raw, envelopes)

async def eth_get_transaction_count(self, address: str, block: str = "pending") -> int:
result = await self._call("eth_getTransactionCount", [address, block])
Expand Down Expand Up @@ -145,32 +179,61 @@ def verify_chain_id(self, expected: int) -> None:
self._verified_chain_id = actual

def _call(self, method: str, params: list[Any]) -> Any:
envelope = self._build_envelope(method, params)
raw = self._transport.post_json("", json=envelope)
return _parse_rpc_response(method, raw)

def _build_envelope(self, method: str, params: list[Any]) -> dict[str, Any]:
self._id += 1
envelope = {
return {
"jsonrpc": "2.0",
"id": self._id,
"method": method,
"params": params,
}
raw = self._transport.post_json("", json=envelope)
if not isinstance(raw, dict):
raise UnexpectedResponseError(f"JSON-RPC {method} returned a non-object response")
response = cast(dict[str, Any], raw)
if "error" in response:
err: Any = response["error"]
code, message, data = _extract_error_fields(err)
raise JsonRpcCallError(method=method, code=code, message=message, data=data)
return response.get("result")

def eth_chain_id(self) -> int:
result = self._call("eth_chainId", [])
return _hex_to_int(result, "eth_chainId")

def eth_call(self, *, to: str, data: str, block: str = "latest") -> str:
result = self._call("eth_call", [{"to": to, "data": data}, block])
if not isinstance(result, str) or not _is_rpc_hex_string(result):
raise UnexpectedResponseError("eth_call did not return a hex string")
return result
return _parse_eth_call_result(result)

def eth_call_batch(
self, requests: Sequence[EthCallBatchRequest], *, block: str = "latest"
) -> list[str]:
if not requests:
return []
return self._eth_call_batch_with_split(requests, block=block)

def _eth_call_batch_with_split(
self, requests: Sequence[EthCallBatchRequest], *, block: str
) -> list[str]:
if len(requests) == 1:
to, data = requests[0]
return [self.eth_call(to=to, data=data, block=block)]

try:
return self._post_eth_call_batch(requests, block=block)
except RequestRejectedError as error:
if error.status < 500:
raise

midpoint = (len(requests) + 1) // 2
left = self._eth_call_batch_with_split(requests[:midpoint], block=block)
right = self._eth_call_batch_with_split(requests[midpoint:], block=block)
return [*left, *right]

def _post_eth_call_batch(
self, requests: Sequence[EthCallBatchRequest], *, block: str
) -> list[str]:
envelopes = [
self._build_envelope("eth_call", [{"to": to, "data": data}, block])
for to, data in requests
]
raw = self._transport.post_json("", json=envelopes)
return _parse_eth_call_batch_response(raw, envelopes)

def eth_get_transaction_count(self, address: str, block: str = "pending") -> int:
result = self._call("eth_getTransactionCount", [address, block])
Expand Down Expand Up @@ -210,6 +273,53 @@ def _extract_error_fields(err: object) -> tuple[int, str, object]:
return 0, str(err), None


def _parse_rpc_response(method: str, raw: object) -> Any:
if not isinstance(raw, dict):
raise UnexpectedResponseError(f"JSON-RPC {method} returned a non-object response")
response = cast(dict[str, Any], raw)
if "error" in response:
err: Any = response["error"]
code, message, data = _extract_error_fields(err)
raise JsonRpcCallError(method=method, code=code, message=message, data=data)
return response.get("result")


def _parse_eth_call_batch_response(raw: object, envelopes: Sequence[dict[str, Any]]) -> list[str]:
if not isinstance(raw, list):
raise UnexpectedResponseError("JSON-RPC eth_call batch returned a non-array response")

responses_by_id: dict[int, dict[str, Any]] = {}
for item in cast(list[object], raw):
if not isinstance(item, dict):
raise UnexpectedResponseError("JSON-RPC eth_call batch returned a non-object item")
response = cast(dict[str, Any], item)
raw_id = response.get("id")
if not isinstance(raw_id, int) or isinstance(raw_id, bool):
raise UnexpectedResponseError(
"JSON-RPC eth_call batch response is missing a numeric id"
)
if raw_id in responses_by_id:
raise UnexpectedResponseError("JSON-RPC eth_call batch returned a duplicate id")
responses_by_id[raw_id] = response

results: list[str] = []
for envelope in envelopes:
raw_id = envelope["id"]
if not isinstance(raw_id, int) or isinstance(raw_id, bool):
raise RuntimeError("JSON-RPC request id must be an integer")
response = responses_by_id.get(raw_id)
if response is None:
raise UnexpectedResponseError("JSON-RPC eth_call batch response is missing an id")
results.append(_parse_eth_call_result(_parse_rpc_response("eth_call", response)))
return results


def _parse_eth_call_result(result: Any) -> str:
if not isinstance(result, str) or not _is_rpc_hex_string(result):
raise UnexpectedResponseError("eth_call did not return a hex string")
return result


def _is_rpc_hex_string(value: str) -> bool:
if not value.startswith("0x"):
return False
Expand All @@ -227,6 +337,7 @@ def _hex_to_int(value: Any, method: str) -> int:


__all__ = [
"EthCallBatchRequest",
"JsonRpcCallError",
"JsonRpcClient",
"SyncJsonRpcClient",
Expand Down
Loading
Loading