From 259832a479cd9a55b7f600665a1df36c150061d4 Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:38:20 +0200 Subject: [PATCH 1/7] feat(client): add execute_transaction --- src/polymarket/clients/async_secure.py | 18 ++++++++++++ src/polymarket/clients/secure.py | 18 ++++++++++++ tests/unit/test_relayer_combo_positions.py | 32 ++++++++++++++++++++++ tests/unit/test_sync_relayer_workflows.py | 32 ++++++++++++++++++++++ 4 files changed, 100 insertions(+) diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index 7bcfe77..273441a 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -2083,6 +2083,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 when the higher-level SDK workflows do + not cover the transaction shape you need. 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( diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index 32c0808..56e3cc5 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -2039,6 +2039,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 when the higher-level SDK workflows do + not cover the transaction shape you need. 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, *, diff --git a/tests/unit/test_relayer_combo_positions.py b/tests/unit/test_relayer_combo_positions.py index 1e7433f..ac4cad4 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,35 @@ 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_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..d0aba44 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,36 @@ 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: + with pytest.raises(UserInputError, match="At least one transaction call is required"): + client.execute_transaction(calls=[]) + + def _stub_binary_positions( # type: ignore[no-untyped-def] client: SecureClient, *, From 8a5fd9d3d9268801cd4db67deac553df3eabd46b Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:42:23 +0200 Subject: [PATCH 2/7] feat(client): add merge_multiple_positions --- .../_internal/actions/relayer/positions.py | 14 +++-- src/polymarket/clients/async_secure.py | 55 +++++++++++++++++++ src/polymarket/clients/secure.py | 55 +++++++++++++++++++ tests/unit/test_relayer_combo_positions.py | 31 +++++++++++ tests/unit/test_sync_relayer_workflows.py | 36 ++++++++++++ 5 files changed, 187 insertions(+), 4 deletions(-) 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 273441a..e94d8da 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, @@ -2300,6 +2301,60 @@ async def merge_positions( ) return await self._dispatch_single_call(call, metadata=resolved_metadata) + async def merge_multiple_positions( + self, + *, + position_ids: Sequence[str], + amount: int | Literal["max"] = "max", + metadata: str | None = None, + ) -> TransactionHandle: + """Merge multiple combo positions back into collateral. + + Args: + position_ids: Combo YES/NO position IDs, one per combo condition. + amount: Base-units position amount to merge for each condition, or + ``"max"`` to merge the largest available balanced amount per condition. + + Returns: + A transaction handle. Await ``wait()`` to wait for a terminal outcome. + """ + if not position_ids: + raise UserInputError("position_ids must include at least one position ID") + + env = self._ctx.environment + seen_conditions: set[str] = set() + calls: list[TransactionCall] = [] + for position_id in position_ids: + 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 56e3cc5..c0dfba8 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, @@ -2194,6 +2195,60 @@ def merge_positions( ) return self._dispatch_single_call(call, metadata=resolved_metadata) + def merge_multiple_positions( + self, + *, + position_ids: Sequence[str], + amount: int | Literal["max"] = "max", + metadata: str | None = None, + ) -> SyncTransactionHandle: + """Merge multiple combo positions back into collateral. + + Args: + position_ids: Combo YES/NO position IDs, one per combo condition. + amount: Base-units position amount to merge for each condition, or + ``"max"`` to merge the largest available balanced amount per condition. + + Returns: + A transaction handle. Call ``wait()`` to wait for a terminal outcome. + """ + if not position_ids: + raise UserInputError("position_ids must include at least one position ID") + + env = self._ctx.environment + seen_conditions: set[str] = set() + calls: list[TransactionCall] = [] + for position_id in position_ids: + 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/tests/unit/test_relayer_combo_positions.py b/tests/unit/test_relayer_combo_positions.py index ac4cad4..566fb37 100644 --- a/tests/unit/test_relayer_combo_positions.py +++ b/tests/unit/test_relayer_combo_positions.py @@ -124,6 +124,37 @@ async def run() -> object: 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( + position_ids=[ + _combo_position("0x03" + "11" * 30, 0), + _combo_position("0x03" + "22" * 30, 1), + _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() + } + + 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 d0aba44..79d7783 100644 --- a/tests/unit/test_sync_relayer_workflows.py +++ b/tests/unit/test_sync_relayer_workflows.py @@ -360,6 +360,38 @@ def test_execute_transaction_rejects_empty_calls() -> None: 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( + position_ids=[ + _combo_position("0x03" + "11" * 30, 0), + _combo_position("0x03" + "22" * 30, 1), + _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() + } + + +def test_merge_multiple_positions_rejects_empty_position_ids() -> None: + with make_sync_deposit_client() as client: + with pytest.raises(UserInputError, match="position_ids must include at least one"): + client.merge_multiple_positions(position_ids=[]) + + def _stub_binary_positions( # type: ignore[no-untyped-def] client: SecureClient, *, @@ -429,6 +461,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] = [] From 5b48192075d6e00207ff74e8c4a30c12e65c8bf2 Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:44:04 +0200 Subject: [PATCH 3/7] docs: add transaction call examples --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index 01c13ee..6cd6032 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,37 @@ 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( + position_ids=[combo_position_id_1, combo_position_id_2, combo_position_id_3], + amount="max", +) + +outcome = handle.wait() +``` + ## API Design See [SDK Direction](docs/sdk-direction.md) for public API design principles and developer-experience decisions. From 7bd5e4b4118cef85402a4f96dc285af2a0ab66a7 Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:45:15 +0200 Subject: [PATCH 4/7] chore: fix transaction test style --- tests/unit/test_relayer_combo_positions.py | 4 +--- tests/unit/test_sync_relayer_workflows.py | 16 ++++++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/unit/test_relayer_combo_positions.py b/tests/unit/test_relayer_combo_positions.py index 566fb37..1d2cfe8 100644 --- a/tests/unit/test_relayer_combo_positions.py +++ b/tests/unit/test_relayer_combo_positions.py @@ -150,9 +150,7 @@ async def run() -> object: 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["target"].lower() for call in calls} == {PRODUCTION.protocol_v2_router.lower()} def test_redeem_positions_market_id_resolves_condition_before_fetching_positions() -> None: diff --git a/tests/unit/test_sync_relayer_workflows.py b/tests/unit/test_sync_relayer_workflows.py index 79d7783..85e73ca 100644 --- a/tests/unit/test_sync_relayer_workflows.py +++ b/tests/unit/test_sync_relayer_workflows.py @@ -355,9 +355,11 @@ def test_execute_transaction_batches_custom_calls() -> None: def test_execute_transaction_rejects_empty_calls() -> None: - with make_sync_deposit_client() as client: - with pytest.raises(UserInputError, match="At least one transaction call is required"): - client.execute_transaction(calls=[]) + 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: @@ -387,9 +389,11 @@ def test_merge_multiple_positions_batches_combo_merges() -> None: def test_merge_multiple_positions_rejects_empty_position_ids() -> None: - with make_sync_deposit_client() as client: - with pytest.raises(UserInputError, match="position_ids must include at least one"): - client.merge_multiple_positions(position_ids=[]) + with ( + make_sync_deposit_client() as client, + pytest.raises(UserInputError, match="position_ids must include at least one"), + ): + client.merge_multiple_positions(position_ids=[]) def _stub_binary_positions( # type: ignore[no-untyped-def] From a38de4060e7dfae80e52d127742f30dec85d770d Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:27:56 +0200 Subject: [PATCH 5/7] docs: clarify execute transaction guidance --- src/polymarket/clients/async_secure.py | 4 ++-- src/polymarket/clients/secure.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index e94d8da..d8761a9 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -2092,8 +2092,8 @@ async def execute_transaction( ) -> TransactionHandle: """Submit one or more transaction calls for the authenticated wallet. - Use this low-level escape hatch when the higher-level SDK workflows do - not cover the transaction shape you need. Calls are executed in order. + 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. diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index c0dfba8..2b48448 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -2048,8 +2048,8 @@ def execute_transaction( ) -> SyncTransactionHandle: """Submit one or more transaction calls for the authenticated wallet. - Use this low-level escape hatch when the higher-level SDK workflows do - not cover the transaction shape you need. Calls are executed in order. + 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. From 6f422a0fd761cea73c79f3f77323ae96f180f703 Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Fri, 26 Jun 2026 14:49:14 +0200 Subject: [PATCH 6/7] feat(client): support per-position merge amounts --- README.md | 7 +++++-- src/polymarket/__init__.py | 2 ++ src/polymarket/clients/async_secure.py | 21 +++++++++++++-------- src/polymarket/clients/secure.py | 21 +++++++++++++-------- src/polymarket/transactions.py | 10 +++++++++- tests/unit/test_relayer_combo_positions.py | 13 +++++++++---- tests/unit/test_sync_relayer_workflows.py | 19 ++++++++++++------- 7 files changed, 63 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 6cd6032..58f84ab 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,11 @@ Batch combo-position merges: ```python handle = client.merge_multiple_positions( - position_ids=[combo_position_id_1, combo_position_id_2, combo_position_id_3], - amount="max", + positions=[ + {"position_id": combo_position_id_1}, + {"position_id": combo_position_id_2, "amount": 1_000_000}, + {"position_id": combo_position_id_3, "amount": 500_000}, + ], ) outcome = handle.wait() 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/clients/async_secure.py b/src/polymarket/clients/async_secure.py index d8761a9..b5988ad 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -225,6 +225,7 @@ from polymarket.transactions import ( DeprecatedTransactionHandle, EoaTransactionHandle, + MergePositionRequest, TransactionHandle, ) from polymarket.types import EvmAddress, HexString @@ -2304,27 +2305,31 @@ async def merge_positions( async def merge_multiple_positions( self, *, - position_ids: Sequence[str], - amount: int | Literal["max"] = "max", + positions: Sequence[MergePositionRequest], metadata: str | None = None, ) -> TransactionHandle: """Merge multiple combo positions back into collateral. Args: - position_ids: Combo YES/NO position IDs, one per combo condition. - amount: Base-units position amount to merge for each condition, or - ``"max"`` to merge the largest available balanced amount per condition. + positions: Combo position merge requests, one per combo condition. + Omit ``amount`` to merge the largest available balanced amount + for that condition. Returns: A transaction handle. Await ``wait()`` to wait for a terminal outcome. """ - if not position_ids: - raise UserInputError("position_ids must include at least one position ID") + 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_id in position_ids: + 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: diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index 2b48448..aa75056 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -190,6 +190,7 @@ from polymarket.models.types import CtfConditionId from polymarket.pagination import Page, Paginator from polymarket.transactions import ( + MergePositionRequest, SyncDeprecatedTransactionHandle, SyncEoaTransactionHandle, SyncTransactionHandle, @@ -2198,27 +2199,31 @@ def merge_positions( def merge_multiple_positions( self, *, - position_ids: Sequence[str], - amount: int | Literal["max"] = "max", + positions: Sequence[MergePositionRequest], metadata: str | None = None, ) -> SyncTransactionHandle: """Merge multiple combo positions back into collateral. Args: - position_ids: Combo YES/NO position IDs, one per combo condition. - amount: Base-units position amount to merge for each condition, or - ``"max"`` to merge the largest available balanced amount per condition. + positions: Combo position merge requests, one per combo condition. + Omit ``amount`` to merge the largest available balanced amount + for that condition. Returns: A transaction handle. Call ``wait()`` to wait for a terminal outcome. """ - if not position_ids: - raise UserInputError("position_ids must include at least one position ID") + 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_id in position_ids: + 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: 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 1d2cfe8..e7dbda5 100644 --- a/tests/unit/test_relayer_combo_positions.py +++ b/tests/unit/test_relayer_combo_positions.py @@ -133,10 +133,10 @@ async def run() -> object: install_rpc_handler(client, _eth_call_result("uint256[]", [100, 60])) try: return await client.merge_multiple_positions( - position_ids=[ - _combo_position("0x03" + "11" * 30, 0), - _combo_position("0x03" + "22" * 30, 1), - _combo_position("0x03" + "33" * 30, 0), + positions=[ + {"position_id": _combo_position("0x03" + "11" * 30, 0), "amount": 1}, + {"position_id": _combo_position("0x03" + "22" * 30, 1)}, + {"position_id": _combo_position("0x03" + "33" * 30, 0), "amount": 3}, ], metadata="Merge selected combo positions", ) @@ -151,6 +151,11 @@ async def run() -> object: 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"{3:064x}", + ] def test_redeem_positions_market_id_resolves_condition_before_fetching_positions() -> None: diff --git a/tests/unit/test_sync_relayer_workflows.py b/tests/unit/test_sync_relayer_workflows.py index 85e73ca..05e19c9 100644 --- a/tests/unit/test_sync_relayer_workflows.py +++ b/tests/unit/test_sync_relayer_workflows.py @@ -369,10 +369,10 @@ def test_merge_multiple_positions_batches_combo_merges() -> None: 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( - position_ids=[ - _combo_position("0x03" + "11" * 30, 0), - _combo_position("0x03" + "22" * 30, 1), - _combo_position("0x03" + "33" * 30, 0), + positions=[ + {"position_id": _combo_position("0x03" + "11" * 30, 0), "amount": 1}, + {"position_id": _combo_position("0x03" + "22" * 30, 1)}, + {"position_id": _combo_position("0x03" + "33" * 30, 0), "amount": 3}, ], metadata="Merge selected combo positions", ) @@ -386,14 +386,19 @@ def test_merge_multiple_positions_batches_combo_merges() -> None: 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"{3:064x}", + ] -def test_merge_multiple_positions_rejects_empty_position_ids() -> None: +def test_merge_multiple_positions_rejects_empty_positions() -> None: with ( make_sync_deposit_client() as client, - pytest.raises(UserInputError, match="position_ids must include at least one"), + pytest.raises(UserInputError, match="positions must include at least one"), ): - client.merge_multiple_positions(position_ids=[]) + client.merge_multiple_positions(positions=[]) def _stub_binary_positions( # type: ignore[no-untyped-def] From 31d2d1bce66e11270fc455d932fc83a5b33c35b3 Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:52:43 +0200 Subject: [PATCH 7/7] docs: clarify max batch merge amounts --- README.md | 2 +- src/polymarket/clients/async_secure.py | 4 ++-- src/polymarket/clients/secure.py | 4 ++-- tests/unit/test_relayer_combo_positions.py | 6 +++--- tests/unit/test_sync_relayer_workflows.py | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 58f84ab..49a9a19 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Batch combo-position merges: handle = client.merge_multiple_positions( positions=[ {"position_id": combo_position_id_1}, - {"position_id": combo_position_id_2, "amount": 1_000_000}, + {"position_id": combo_position_id_2, "amount": "max"}, {"position_id": combo_position_id_3, "amount": 500_000}, ], ) diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index b5988ad..7509f3f 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -2312,8 +2312,8 @@ async def merge_multiple_positions( Args: positions: Combo position merge requests, one per combo condition. - Omit ``amount`` to merge the largest available balanced amount - for that 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. diff --git a/src/polymarket/clients/secure.py b/src/polymarket/clients/secure.py index aa75056..68f10aa 100644 --- a/src/polymarket/clients/secure.py +++ b/src/polymarket/clients/secure.py @@ -2206,8 +2206,8 @@ def merge_multiple_positions( Args: positions: Combo position merge requests, one per combo condition. - Omit ``amount`` to merge the largest available balanced amount - for that 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. diff --git a/tests/unit/test_relayer_combo_positions.py b/tests/unit/test_relayer_combo_positions.py index e7dbda5..cf4c84c 100644 --- a/tests/unit/test_relayer_combo_positions.py +++ b/tests/unit/test_relayer_combo_positions.py @@ -135,8 +135,8 @@ async def run() -> object: 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)}, - {"position_id": _combo_position("0x03" + "33" * 30, 0), "amount": 3}, + {"position_id": _combo_position("0x03" + "22" * 30, 1), "amount": "max"}, + {"position_id": _combo_position("0x03" + "33" * 30, 0)}, ], metadata="Merge selected combo positions", ) @@ -154,7 +154,7 @@ async def run() -> object: assert [call["data"][-64:] for call in calls] == [ f"{1:064x}", f"{60:064x}", - f"{3:064x}", + f"{60:064x}", ] diff --git a/tests/unit/test_sync_relayer_workflows.py b/tests/unit/test_sync_relayer_workflows.py index 05e19c9..1fdb712 100644 --- a/tests/unit/test_sync_relayer_workflows.py +++ b/tests/unit/test_sync_relayer_workflows.py @@ -371,8 +371,8 @@ def test_merge_multiple_positions_batches_combo_merges() -> None: handle = client.merge_multiple_positions( positions=[ {"position_id": _combo_position("0x03" + "11" * 30, 0), "amount": 1}, - {"position_id": _combo_position("0x03" + "22" * 30, 1)}, - {"position_id": _combo_position("0x03" + "33" * 30, 0), "amount": 3}, + {"position_id": _combo_position("0x03" + "22" * 30, 1), "amount": "max"}, + {"position_id": _combo_position("0x03" + "33" * 30, 0)}, ], metadata="Merge selected combo positions", ) @@ -389,7 +389,7 @@ def test_merge_multiple_positions_batches_combo_merges() -> None: assert [call["data"][-64:] for call in inner_calls] == [ f"{1:064x}", f"{60:064x}", - f"{3:064x}", + f"{60:064x}", ]