diff --git a/README.md b/README.md index 01c13ee..49a9a19 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,40 @@ async def main() -> None: asyncio.run(main()) ``` +Custom transaction calls: + +```python +from polymarket.calls import merge_v2_call +from polymarket.types import EvmAddress + +router = EvmAddress(client.environment.protocol_v2_router) + +handle = client.execute_transaction( + calls=[ + merge_v2_call(router=router, condition_id="0x03...", amount=1_000_000), + merge_v2_call(router=router, condition_id="0x03...", amount=2_000_000), + merge_v2_call(router=router, condition_id="0x03...", amount=500_000), + ], + metadata="Merge 3 combo positions", +) + +outcome = handle.wait() +``` + +Batch combo-position merges: + +```python +handle = client.merge_multiple_positions( + positions=[ + {"position_id": combo_position_id_1}, + {"position_id": combo_position_id_2, "amount": "max"}, + {"position_id": combo_position_id_3, "amount": 500_000}, + ], +) + +outcome = handle.wait() +``` + ## API Design See [SDK Direction](docs/sdk-direction.md) for public API design principles and developer-experience decisions. diff --git a/src/polymarket/__init__.py b/src/polymarket/__init__.py index 75b19cb..e9139c5 100644 --- a/src/polymarket/__init__.py +++ b/src/polymarket/__init__.py @@ -157,6 +157,7 @@ from polymarket.transactions import ( EoaTransactionHandle, GaslessTransactionHandle, + MergePositionRequest, SyncEoaTransactionHandle, SyncGaslessTransactionHandle, SyncTransactionHandle, @@ -232,6 +233,7 @@ "MarketRewardToken", "MarketVolume", "MergeActivity", + "MergePositionRequest", "MetaHolder", "MetaMarketPosition", "Notification", diff --git a/src/polymarket/_internal/actions/relayer/positions.py b/src/polymarket/_internal/actions/relayer/positions.py index 5989edf..0e80e44 100644 --- a/src/polymarket/_internal/actions/relayer/positions.py +++ b/src/polymarket/_internal/actions/relayer/positions.py @@ -243,10 +243,15 @@ def derive_combo_position_context(legs: CanonicalComboLegs) -> ComboPositionCont condition_id = to_combo_condition_id(f"0x03{base_hash.hex()[32:]}{'0' * 28}") return ComboPositionContext( condition_id=condition_id, - position_ids=( - PositionId(str(int(f"{condition_id}00", 16))), - PositionId(str(int(f"{condition_id}01", 16))), - ), + position_ids=derive_combo_outcome_position_ids(condition_id), + ) + + +def derive_combo_outcome_position_ids(condition_id: str) -> tuple[PositionId, PositionId]: + combo_condition_id = to_combo_condition_id(condition_id) + return ( + PositionId(str(int(f"{combo_condition_id}00", 16))), + PositionId(str(int(f"{combo_condition_id}01", 16))), ) @@ -303,6 +308,7 @@ def _parse_position_id(position_id: str) -> int: "calculate_max_merge_amount_from_balances", "canonicalize_combo_legs", "decode_combo_outcome_position_id", + "derive_combo_outcome_position_ids", "derive_combo_position_context", "expect_binary_positions", "expect_negative_risk_flag", diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index 7bcfe77..7509f3f 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -104,6 +104,7 @@ MarketPositionContext, canonicalize_combo_legs, decode_combo_outcome_position_id, + derive_combo_outcome_position_ids, derive_combo_position_context, normalize_market_position_context, parse_market_id, @@ -224,6 +225,7 @@ from polymarket.transactions import ( DeprecatedTransactionHandle, EoaTransactionHandle, + MergePositionRequest, TransactionHandle, ) from polymarket.types import EvmAddress, HexString @@ -2083,6 +2085,24 @@ async def is_gasless_ready(self) -> bool: """ return True + async def execute_transaction( + self, + *, + calls: Sequence[TransactionCall], + metadata: str | None = None, + ) -> TransactionHandle: + """Submit one or more transaction calls for the authenticated wallet. + + Use this low-level escape hatch to combine supported transaction calls + differently than the higher-level SDK workflows. Calls are executed in order. + + Returns: + A transaction handle. Await ``wait()`` to wait for a terminal outcome. + """ + return await self._dispatch_calls( + list(calls), metadata=metadata if metadata is not None else "Execute transaction" + ) + async def _broadcast_eoa_call(self, call: TransactionCall) -> EoaTransactionHandle: env = self._ctx.environment return await broadcast_eoa_call( @@ -2282,6 +2302,64 @@ async def merge_positions( ) return await self._dispatch_single_call(call, metadata=resolved_metadata) + async def merge_multiple_positions( + self, + *, + positions: Sequence[MergePositionRequest], + metadata: str | None = None, + ) -> TransactionHandle: + """Merge multiple combo positions back into collateral. + + Args: + positions: Combo position merge requests, one per combo condition. + Omit ``amount`` or pass ``"max"`` to merge the largest available + balanced amount for that condition. + + Returns: + A transaction handle. Await ``wait()`` to wait for a terminal outcome. + """ + if not positions: + raise UserInputError("positions must include at least one merge request") + + env = self._ctx.environment + seen_conditions: set[str] = set() + calls: list[TransactionCall] = [] + for position in positions: + try: + position_id = position["position_id"] + except KeyError as error: + raise UserInputError("Each merge request must include position_id") from error + amount = position.get("amount", "max") + decoded = decode_combo_outcome_position_id(position_id) + condition_key = str(decoded.condition_id) + if condition_key in seen_conditions: + raise UserInputError("position_ids must reference distinct combo conditions") + seen_conditions.add(condition_key) + token_ids = derive_combo_outcome_position_ids(decoded.condition_id) + balance_call = erc1155_balance_of_batch_call( + token_address=cast(EvmAddress, env.position_manager), + owners=[self._ctx.wallet, self._ctx.wallet], + token_ids=list(token_ids), + ) + balances = decode_erc1155_balance_of_batch_result( + await self._ctx.rpc.eth_call(to=str(balance_call.to), data=balance_call.data) + ) + resolved_amount = resolve_merge_amount_from_balances( + decoded.condition_id, balances, amount + ) + calls.append( + merge_v2_call( + router=cast(EvmAddress, env.protocol_v2_router), + condition_id=decoded.condition_id, + amount=resolved_amount, + ) + ) + + resolved_metadata = ( + metadata if metadata is not None else f"Merge {len(calls)} combo positions" + ) + return await self.execute_transaction(calls=calls, metadata=resolved_metadata) + async def redeem_positions( self, *, diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index 32c0808..68f10aa 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -94,6 +94,7 @@ MarketPositionContext, canonicalize_combo_legs, decode_combo_outcome_position_id, + derive_combo_outcome_position_ids, derive_combo_position_context, normalize_market_position_context, parse_market_id, @@ -189,6 +190,7 @@ from polymarket.models.types import CtfConditionId from polymarket.pagination import Page, Paginator from polymarket.transactions import ( + MergePositionRequest, SyncDeprecatedTransactionHandle, SyncEoaTransactionHandle, SyncTransactionHandle, @@ -2039,6 +2041,24 @@ def is_gasless_ready(self) -> bool: """ return True + def execute_transaction( + self, + *, + calls: Sequence[TransactionCall], + metadata: str | None = None, + ) -> SyncTransactionHandle: + """Submit one or more transaction calls for the authenticated wallet. + + Use this low-level escape hatch to combine supported transaction calls + differently than the higher-level SDK workflows. Calls are executed in order. + + Returns: + A transaction handle. Call ``wait()`` to wait for a terminal outcome. + """ + return self._dispatch_calls( + list(calls), metadata=metadata if metadata is not None else "Execute transaction" + ) + def split_position( self, *, @@ -2176,6 +2196,64 @@ def merge_positions( ) return self._dispatch_single_call(call, metadata=resolved_metadata) + def merge_multiple_positions( + self, + *, + positions: Sequence[MergePositionRequest], + metadata: str | None = None, + ) -> SyncTransactionHandle: + """Merge multiple combo positions back into collateral. + + Args: + positions: Combo position merge requests, one per combo condition. + Omit ``amount`` or pass ``"max"`` to merge the largest available + balanced amount for that condition. + + Returns: + A transaction handle. Call ``wait()`` to wait for a terminal outcome. + """ + if not positions: + raise UserInputError("positions must include at least one merge request") + + env = self._ctx.environment + seen_conditions: set[str] = set() + calls: list[TransactionCall] = [] + for position in positions: + try: + position_id = position["position_id"] + except KeyError as error: + raise UserInputError("Each merge request must include position_id") from error + amount = position.get("amount", "max") + decoded = decode_combo_outcome_position_id(position_id) + condition_key = str(decoded.condition_id) + if condition_key in seen_conditions: + raise UserInputError("position_ids must reference distinct combo conditions") + seen_conditions.add(condition_key) + token_ids = derive_combo_outcome_position_ids(decoded.condition_id) + balance_call = erc1155_balance_of_batch_call( + token_address=cast(EvmAddress, env.position_manager), + owners=[self._ctx.wallet, self._ctx.wallet], + token_ids=list(token_ids), + ) + balances = decode_erc1155_balance_of_batch_result( + self._ctx.rpc.eth_call(to=str(balance_call.to), data=balance_call.data) + ) + resolved_amount = resolve_merge_amount_from_balances( + decoded.condition_id, balances, amount + ) + calls.append( + merge_v2_call( + router=cast(EvmAddress, env.protocol_v2_router), + condition_id=decoded.condition_id, + amount=resolved_amount, + ) + ) + + resolved_metadata = ( + metadata if metadata is not None else f"Merge {len(calls)} combo positions" + ) + return self.execute_transaction(calls=calls, metadata=resolved_metadata) + def redeem_positions( self, *, diff --git a/src/polymarket/transactions.py b/src/polymarket/transactions.py index 5d6b9c5..507c3c0 100644 --- a/src/polymarket/transactions.py +++ b/src/polymarket/transactions.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import TYPE_CHECKING, TypeAlias +from typing import TYPE_CHECKING, Literal, NotRequired, TypeAlias, TypedDict from polymarket.models.clob.relayer import TransactionOutcome @@ -10,6 +10,13 @@ from polymarket.clients._transport import AsyncTransport, SyncTransport +class MergePositionRequest(TypedDict): + """Combo position merge request used by batch merge workflows.""" + + position_id: str + amount: NotRequired[int | Literal["max"]] + + @dataclass(frozen=True, slots=True) class GaslessTransactionHandle: """Async handle for a relayed gasless transaction.""" @@ -142,6 +149,7 @@ def wait(self) -> None: __all__ = [ "EoaTransactionHandle", "GaslessTransactionHandle", + "MergePositionRequest", "DeprecatedTransactionHandle", "SyncDeprecatedTransactionHandle", "SyncEoaTransactionHandle", diff --git a/tests/unit/test_relayer_combo_positions.py b/tests/unit/test_relayer_combo_positions.py index 1e7433f..cf4c84c 100644 --- a/tests/unit/test_relayer_combo_positions.py +++ b/tests/unit/test_relayer_combo_positions.py @@ -18,8 +18,11 @@ from eth_utils.crypto import keccak from polymarket import AsyncSecureClient +from polymarket.calls import merge_v2_call from polymarket.environments import PRODUCTION from polymarket.errors import UnexpectedResponseError, UserInputError +from polymarket.transactions import GaslessTransactionHandle +from polymarket.types import EvmAddress _COMBO_CONDITION_ID = "0x032def24bfb0c5c57fb236fac08b94236a0000000000000000000000000000" _CONDITION_ID = "0x" + "11" * 32 @@ -92,6 +95,69 @@ async def run() -> None: assert body["metadata"] == f"Redeem combo position {position_id}" +def test_execute_transaction_async_batches_custom_calls() -> None: + captured: list[httpx.Request] = [] + router = EvmAddress(PRODUCTION.protocol_v2_router) + + async def run() -> object: + client = await make_deposit_client() + _setup_relayer(client, captured, "tx-execute") + try: + return await client.execute_transaction( + calls=[ + merge_v2_call(router=router, condition_id="0x03" + "11" * 30, amount=1), + merge_v2_call(router=router, condition_id="0x03" + "22" * 30, amount=2), + merge_v2_call(router=router, condition_id="0x03" + "33" * 30, amount=3), + ], + metadata="Merge 3 combo positions", + ) + finally: + await client.close() + + handle = asyncio.run(run()) + + assert isinstance(handle, GaslessTransactionHandle) + body = _submit_body(captured) + calls = _deposit_wallet_calls(body) + assert len(calls) == 3 + assert body["metadata"] == "Merge 3 combo positions" + assert {call["target"].lower() for call in calls} == {router.lower()} + + +def test_merge_multiple_positions_async_batches_combo_merges() -> None: + captured: list[httpx.Request] = [] + + async def run() -> object: + client = await make_deposit_client() + _setup_relayer(client, captured, "tx-merge-multiple") + install_rpc_handler(client, _eth_call_result("uint256[]", [100, 60])) + try: + return await client.merge_multiple_positions( + positions=[ + {"position_id": _combo_position("0x03" + "11" * 30, 0), "amount": 1}, + {"position_id": _combo_position("0x03" + "22" * 30, 1), "amount": "max"}, + {"position_id": _combo_position("0x03" + "33" * 30, 0)}, + ], + metadata="Merge selected combo positions", + ) + finally: + await client.close() + + handle = asyncio.run(run()) + + assert isinstance(handle, GaslessTransactionHandle) + body = _submit_body(captured) + calls = _deposit_wallet_calls(body) + assert len(calls) == 3 + assert body["metadata"] == "Merge selected combo positions" + assert {call["target"].lower() for call in calls} == {PRODUCTION.protocol_v2_router.lower()} + assert [call["data"][-64:] for call in calls] == [ + f"{1:064x}", + f"{60:064x}", + f"{60:064x}", + ] + + def test_redeem_positions_market_id_resolves_condition_before_fetching_positions() -> None: captured: list[httpx.Request] = [] market_calls: list[dict[str, object]] = [] diff --git a/tests/unit/test_sync_relayer_workflows.py b/tests/unit/test_sync_relayer_workflows.py index 7f74995..1fdb712 100644 --- a/tests/unit/test_sync_relayer_workflows.py +++ b/tests/unit/test_sync_relayer_workflows.py @@ -21,6 +21,7 @@ from eth_abi.abi import encode as abi_encode from polymarket import SecureClient +from polymarket.calls import merge_v2_call from polymarket.environments import PRODUCTION from polymarket.errors import ( TimeoutError as PolyTimeoutError, @@ -31,6 +32,7 @@ UserInputError, ) from polymarket.transactions import SyncEoaTransactionHandle, SyncGaslessTransactionHandle +from polymarket.types import EvmAddress _CONDITION_ID = "0x" + "11" * 32 @@ -328,6 +330,77 @@ def test_setup_trading_approvals_safe_uses_multisend_delegatecall() -> None: assert body["to"].lower() == PRODUCTION.safe_multisend.lower() +def test_execute_transaction_batches_custom_calls() -> None: + captured: list[httpx.Request] = [] + router = EvmAddress(PRODUCTION.protocol_v2_router) + + with make_sync_deposit_client() as client: + install_sync_relayer_handler(client, _deposit_relayer_handler(captured)) + handle = client.execute_transaction( + calls=[ + merge_v2_call(router=router, condition_id="0x03" + "11" * 30, amount=1), + merge_v2_call(router=router, condition_id="0x03" + "22" * 30, amount=2), + merge_v2_call(router=router, condition_id="0x03" + "33" * 30, amount=3), + ], + metadata="Merge 3 combo positions", + ) + + assert isinstance(handle, SyncGaslessTransactionHandle) + submit = [r for r in captured if urlparse(str(r.url)).path == "/submit"][0] + body = request_json(submit) + assert body["metadata"] == "Merge 3 combo positions" + inner_calls = body["depositWalletParams"]["calls"] + assert len(inner_calls) == 3 + assert {call["target"].lower() for call in inner_calls} == {router.lower()} + + +def test_execute_transaction_rejects_empty_calls() -> None: + with ( + make_sync_deposit_client() as client, + pytest.raises(UserInputError, match="At least one transaction call is required"), + ): + client.execute_transaction(calls=[]) + + +def test_merge_multiple_positions_batches_combo_merges() -> None: + captured: list[httpx.Request] = [] + + with make_sync_deposit_client() as client: + install_sync_relayer_handler(client, _deposit_relayer_handler(captured)) + install_sync_rpc_handler(client, _eth_call_result("uint256[]", [100, 60])) + handle = client.merge_multiple_positions( + positions=[ + {"position_id": _combo_position("0x03" + "11" * 30, 0), "amount": 1}, + {"position_id": _combo_position("0x03" + "22" * 30, 1), "amount": "max"}, + {"position_id": _combo_position("0x03" + "33" * 30, 0)}, + ], + metadata="Merge selected combo positions", + ) + + assert isinstance(handle, SyncGaslessTransactionHandle) + submit = [r for r in captured if urlparse(str(r.url)).path == "/submit"][0] + body = request_json(submit) + inner_calls = body["depositWalletParams"]["calls"] + assert len(inner_calls) == 3 + assert body["metadata"] == "Merge selected combo positions" + assert {call["target"].lower() for call in inner_calls} == { + PRODUCTION.protocol_v2_router.lower() + } + assert [call["data"][-64:] for call in inner_calls] == [ + f"{1:064x}", + f"{60:064x}", + f"{60:064x}", + ] + + +def test_merge_multiple_positions_rejects_empty_positions() -> None: + with ( + make_sync_deposit_client() as client, + pytest.raises(UserInputError, match="positions must include at least one"), + ): + client.merge_multiple_positions(positions=[]) + + def _stub_binary_positions( # type: ignore[no-untyped-def] client: SecureClient, *, @@ -397,6 +470,10 @@ def first_page(self): # type: ignore[no-untyped-def] return _StubPaginator() +def _combo_position(condition_id: str, outcome: int) -> str: + return str(int(f"{condition_id}{outcome:02x}", 16)) + + def test_merge_positions_routes_through_collateral_adapter() -> None: captured: list[httpx.Request] = []