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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/polymarket/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@
from polymarket.transactions import (
EoaTransactionHandle,
GaslessTransactionHandle,
MergePositionRequest,
SyncEoaTransactionHandle,
SyncGaslessTransactionHandle,
SyncTransactionHandle,
Expand Down Expand Up @@ -232,6 +233,7 @@
"MarketRewardToken",
"MarketVolume",
"MergeActivity",
"MergePositionRequest",
"MetaHolder",
"MetaMarketPosition",
"Notification",
Expand Down
14 changes: 10 additions & 4 deletions src/polymarket/_internal/actions/relayer/positions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))),
)


Expand Down Expand Up @@ -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",
Expand Down
78 changes: 78 additions & 0 deletions src/polymarket/clients/async_secure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -224,6 +225,7 @@
from polymarket.transactions import (
DeprecatedTransactionHandle,
EoaTransactionHandle,
MergePositionRequest,
TransactionHandle,
)
from polymarket.types import EvmAddress, HexString
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
*,
Expand Down
78 changes: 78 additions & 0 deletions src/polymarket/clients/secure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -189,6 +190,7 @@
from polymarket.models.types import CtfConditionId
from polymarket.pagination import Page, Paginator
from polymarket.transactions import (
MergePositionRequest,
SyncDeprecatedTransactionHandle,
SyncEoaTransactionHandle,
SyncTransactionHandle,
Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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,
*,
Expand Down
10 changes: 9 additions & 1 deletion src/polymarket/transactions.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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."""
Expand Down Expand Up @@ -142,6 +149,7 @@ def wait(self) -> None:
__all__ = [
"EoaTransactionHandle",
"GaslessTransactionHandle",
"MergePositionRequest",
"DeprecatedTransactionHandle",
"SyncDeprecatedTransactionHandle",
"SyncEoaTransactionHandle",
Expand Down
Loading
Loading