pmwallets 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ name: ci
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+ jobs:
7
+ test:
8
+ runs-on: ubuntu-latest
9
+ strategy:
10
+ matrix:
11
+ python: ["3.10", "3.12"]
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: ${{ matrix.python }}
17
+ - run: pip install -e '.[dev]'
18
+ - run: pytest -q
@@ -0,0 +1,24 @@
1
+ # Publishes to PyPI through a Trusted Publisher (OIDC): no token is stored anywhere.
2
+ # Release = push a tag v<version> matching pyproject.toml.
3
+ name: publish
4
+ on:
5
+ push:
6
+ tags: ["v*"]
7
+ jobs:
8
+ pypi:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write
13
+ contents: read
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.12"
19
+ - name: tag must match the package version
20
+ run: |
21
+ v=$(python -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
22
+ test "v$v" = "$GITHUB_REF_NAME" || { echo "tag $GITHUB_REF_NAME != v$v"; exit 1; }
23
+ - run: pip install build && python -m build
24
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .venv/
4
+ .pytest_cache/
5
+ build/
6
+ dist/
7
+ .env
8
+ config.yaml
9
+ pmw-data/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PMWallets
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.5
2
+ Name: pmwallets
3
+ Version: 0.1.0
4
+ Summary: Official PMWallets SDK: Polymarket smart-money leaderboard, real-time wallet fills over WebSocket with gap replay, webhooks and exports.
5
+ Project-URL: Homepage, https://pmwallets.com/docs
6
+ Project-URL: Repository, https://github.com/polymarketwallets/pmwallets-python
7
+ Project-URL: Issues, https://github.com/polymarketwallets/pmwallets-python/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: copy-trading,pmwallets,polymarket,prediction-markets,smart-money,wallet-tracker
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: httpx>=0.27
13
+ Requires-Dist: websockets>=15
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
16
+ Requires-Dist: pytest>=8; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # pmwallets — Python SDK for PMWallets
20
+
21
+ [![PyPI](https://img.shields.io/pypi/v/pmwallets.svg)](https://pypi.org/project/pmwallets/) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
22
+
23
+ Official Python client for [PMWallets](https://pmwallets.com): the **Polymarket smart-money leaderboard** computed
24
+ from the Polygon chain, and the **real-time fills of the Polymarket wallets you follow** — delivered in order,
25
+ exactly once, with every gap replayed.
26
+
27
+ [中文说明](README.zh.md) · Node.js SDK: [pmwallets-node](https://github.com/polymarketwallets/pmwallets-node) ·
28
+ Ready-to-run copy-trading bot built on it: [polymarket-copy-trading-bot-python](https://github.com/polymarketwallets/polymarket-copy-trading-bot-python)
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install pmwallets # Python ≥ 3.10
34
+ ```
35
+
36
+ Create an API key on [pmwallets.com/keys](https://pmwallets.com/keys).
37
+
38
+ ## Usage
39
+
40
+ ```python
41
+ import asyncio
42
+ from pmwallets import AsyncClient, Client, FillStream, FileStateStore
43
+
44
+ # synchronous REST
45
+ with Client(api_key="pmw_...") as pmw:
46
+ board = pmw.leaderboard(minWinLo=0.55, minEligible=30, style="taker", status="active", limit=50)
47
+ pmw.subscribe(board["rows"][0]["entityId"], channels=["ws"]) # billed per entity per hour
48
+
49
+ # every fill of every entity you follow
50
+ async def main():
51
+ async with AsyncClient(api_key="pmw_...") as pmw:
52
+ stream = FillStream(
53
+ client=pmw,
54
+ store=FileStateStore("stream.json"), # a restart resumes exactly where it stopped
55
+ on_fill=lambda fill, meta: print(meta.source, fill["side"], fill["price"], fill["tokenId"]),
56
+ )
57
+ await stream.run()
58
+
59
+ asyncio.run(main())
60
+ ```
61
+
62
+ ## What is in it
63
+
64
+ | | |
65
+ |---|---|
66
+ | `Client` / `AsyncClient` | leaderboard, entities, address unlocks, subscriptions, fills replay (`fills`, `fills_since`), trade-history exports |
67
+ | `FillStream` | asyncio WebSocket with reconnect and keep-alive; detects missed frames by `session`/`seq` and replays them from the last fill delivered; de-duplicates by `eventId`; anchors behind the chain head on first start; persisted cursor |
68
+ | `verify_webhook(raw_body, signature, secret)` | checks `x-pmw-signature` (HMAC-SHA256 of the raw body) |
69
+
70
+ One stream per account: the newest connection wins, so run one consumer per API account. `HTTPS_PROXY` is honoured.
71
+
72
+ ## Resources
73
+
74
+ - [Polymarket smart-money leaderboard](https://pmwallets.com) — profitable Polymarket traders scored from the Polygon chain, with win-rate confidence intervals
75
+ - [Polymarket copy trading guide](https://pmwallets.com/copy-trading) — which wallets are worth following and how to get their fills in time
76
+ - [How to learn from Polymarket smart money](https://pmwallets.com/learn) — reading a trader's record: confidence intervals, maker vs taker, market specialism
77
+ - [PMWallets API documentation](https://pmwallets.com/docs) — WebSocket and webhook fill push, fills replay, trade-history exports
78
+ - [Measured fill-push latency](https://pmwallets.com/latency) — block-to-push p50 / p95, published live
79
+ - [Ways to follow Polymarket wallets, compared](https://pmwallets.com/compare) — official leaderboard, free trackers, SQL dashboards
80
+ - [FAQ](https://pmwallets.com/faq) · [中文站](https://pmwallets.com/zh)
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,66 @@
1
+ # pmwallets — Python SDK for PMWallets
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/pmwallets.svg)](https://pypi.org/project/pmwallets/) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
4
+
5
+ Official Python client for [PMWallets](https://pmwallets.com): the **Polymarket smart-money leaderboard** computed
6
+ from the Polygon chain, and the **real-time fills of the Polymarket wallets you follow** — delivered in order,
7
+ exactly once, with every gap replayed.
8
+
9
+ [中文说明](README.zh.md) · Node.js SDK: [pmwallets-node](https://github.com/polymarketwallets/pmwallets-node) ·
10
+ Ready-to-run copy-trading bot built on it: [polymarket-copy-trading-bot-python](https://github.com/polymarketwallets/polymarket-copy-trading-bot-python)
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ pip install pmwallets # Python ≥ 3.10
16
+ ```
17
+
18
+ Create an API key on [pmwallets.com/keys](https://pmwallets.com/keys).
19
+
20
+ ## Usage
21
+
22
+ ```python
23
+ import asyncio
24
+ from pmwallets import AsyncClient, Client, FillStream, FileStateStore
25
+
26
+ # synchronous REST
27
+ with Client(api_key="pmw_...") as pmw:
28
+ board = pmw.leaderboard(minWinLo=0.55, minEligible=30, style="taker", status="active", limit=50)
29
+ pmw.subscribe(board["rows"][0]["entityId"], channels=["ws"]) # billed per entity per hour
30
+
31
+ # every fill of every entity you follow
32
+ async def main():
33
+ async with AsyncClient(api_key="pmw_...") as pmw:
34
+ stream = FillStream(
35
+ client=pmw,
36
+ store=FileStateStore("stream.json"), # a restart resumes exactly where it stopped
37
+ on_fill=lambda fill, meta: print(meta.source, fill["side"], fill["price"], fill["tokenId"]),
38
+ )
39
+ await stream.run()
40
+
41
+ asyncio.run(main())
42
+ ```
43
+
44
+ ## What is in it
45
+
46
+ | | |
47
+ |---|---|
48
+ | `Client` / `AsyncClient` | leaderboard, entities, address unlocks, subscriptions, fills replay (`fills`, `fills_since`), trade-history exports |
49
+ | `FillStream` | asyncio WebSocket with reconnect and keep-alive; detects missed frames by `session`/`seq` and replays them from the last fill delivered; de-duplicates by `eventId`; anchors behind the chain head on first start; persisted cursor |
50
+ | `verify_webhook(raw_body, signature, secret)` | checks `x-pmw-signature` (HMAC-SHA256 of the raw body) |
51
+
52
+ One stream per account: the newest connection wins, so run one consumer per API account. `HTTPS_PROXY` is honoured.
53
+
54
+ ## Resources
55
+
56
+ - [Polymarket smart-money leaderboard](https://pmwallets.com) — profitable Polymarket traders scored from the Polygon chain, with win-rate confidence intervals
57
+ - [Polymarket copy trading guide](https://pmwallets.com/copy-trading) — which wallets are worth following and how to get their fills in time
58
+ - [How to learn from Polymarket smart money](https://pmwallets.com/learn) — reading a trader's record: confidence intervals, maker vs taker, market specialism
59
+ - [PMWallets API documentation](https://pmwallets.com/docs) — WebSocket and webhook fill push, fills replay, trade-history exports
60
+ - [Measured fill-push latency](https://pmwallets.com/latency) — block-to-push p50 / p95, published live
61
+ - [Ways to follow Polymarket wallets, compared](https://pmwallets.com/compare) — official leaderboard, free trackers, SQL dashboards
62
+ - [FAQ](https://pmwallets.com/faq) · [中文站](https://pmwallets.com/zh)
63
+
64
+ ## License
65
+
66
+ MIT
@@ -0,0 +1,41 @@
1
+ # pmwallets —— PMWallets Python SDK
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/pmwallets.svg)](https://pypi.org/project/pmwallets/) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE)
4
+
5
+ [PMWallets](https://pmwallets.com/zh) 官方 Python 客户端:从 Polygon 链上计算的 **Polymarket 聪明钱排行榜**,以及**你所跟踪的
6
+ Polymarket 钱包的实时成交** —— 按顺序、只送一次、漏掉的自动补发。
7
+
8
+ [English](README.md) · Node.js SDK:[pmwallets-node](https://github.com/polymarketwallets/pmwallets-node) ·
9
+ 基于它的开箱即用跟单机器人:[polymarket-copy-trading-bot-python](https://github.com/polymarketwallets/polymarket-copy-trading-bot-python)
10
+
11
+ ## 安装
12
+
13
+ ```bash
14
+ pip install pmwallets # Python ≥ 3.10
15
+ ```
16
+
17
+ 在 [pmwallets.com/keys](https://pmwallets.com/keys) 创建 API key。用法示例见 [README.md](README.md#usage)。
18
+
19
+ ## 包含什么
20
+
21
+ | | |
22
+ |---|---|
23
+ | `Client` / `AsyncClient` | 排行榜、实体、地址解锁、订阅、成交补发(`fills`、`fills_since`)、交易历史导出 |
24
+ | `FillStream` | asyncio WebSocket,自动重连与保活;按 `session`/`seq` 发现漏帧并从最后送达的成交补发;按 `eventId` 去重;首次启动锚定在链头之后;游标持久化 |
25
+ | `verify_webhook(raw_body, signature, secret)` | 校验 `x-pmw-signature`(原始 body 的 HMAC-SHA256) |
26
+
27
+ 每个账户只有一条推送流,后连上的会顶掉先连的。支持 `HTTPS_PROXY`。
28
+
29
+ ## 相关链接
30
+
31
+ - [Polymarket 聪明钱排行榜](https://pmwallets.com/zh) —— 从 Polygon 链上计算的 Polymarket 盈利交易者,胜率带置信区间
32
+ - [Polymarket 跟单指南](https://pmwallets.com/zh/copy-trading) —— 哪些钱包值得跟,以及怎样及时拿到他们的成交
33
+ - [怎样向 Polymarket 聪明钱学习](https://pmwallets.com/zh/learn) —— 读懂一份战绩:置信区间、挂单与吃单、擅长的市场
34
+ - [PMWallets API 文档](https://pmwallets.com/zh/docs) —— WebSocket 与 Webhook 成交推送、补发接口、交易历史导出
35
+ - [成交推送实测延迟](https://pmwallets.com/zh/latency) —— 出块到推送的 p50 / p95,实时公布
36
+ - [追踪 Polymarket 钱包的几种做法对比](https://pmwallets.com/zh/compare) —— 官方榜单、免费追踪器、SQL 看板
37
+ - [常见问题](https://pmwallets.com/zh/faq) · [English site](https://pmwallets.com)
38
+
39
+ ## 许可证
40
+
41
+ MIT
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pmwallets"
7
+ version = "0.1.0"
8
+ description = "Official PMWallets SDK: Polymarket smart-money leaderboard, real-time wallet fills over WebSocket with gap replay, webhooks and exports."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ keywords = ["polymarket", "copy-trading", "wallet-tracker", "smart-money", "pmwallets", "prediction-markets"]
13
+ dependencies = ["httpx>=0.27", "websockets>=15"]
14
+
15
+ [project.optional-dependencies]
16
+ dev = ["pytest>=8", "pytest-asyncio>=0.23"]
17
+
18
+ [project.urls]
19
+ Homepage = "https://pmwallets.com/docs"
20
+ Repository = "https://github.com/polymarketwallets/pmwallets-python"
21
+ Issues = "https://github.com/polymarketwallets/pmwallets-python/issues"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/pmwallets"]
25
+
26
+ [tool.pytest.ini_options]
27
+ asyncio_mode = "auto"
28
+ testpaths = ["tests"]
@@ -0,0 +1,13 @@
1
+ """Official PMWallets SDK — https://pmwallets.com/docs"""
2
+
3
+ from .client import DEFAULT_BASE_URL, AsyncClient, Client, PmwError
4
+ from .stream import FileStateStore, FillMeta, FillStream, MemoryStateStore, StreamState, UpgradeRefused, websockets_connector
5
+ from .types import Fill, FillCursor, FillsPage
6
+ from .webhook import verify_webhook
7
+
8
+ __all__ = [
9
+ "DEFAULT_BASE_URL", "AsyncClient", "Client", "PmwError",
10
+ "FileStateStore", "FillMeta", "FillStream", "MemoryStateStore", "StreamState", "UpgradeRefused", "websockets_connector",
11
+ "Fill", "FillCursor", "FillsPage", "verify_webhook",
12
+ ]
13
+ __version__ = "0.1.0"
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any, AsyncIterator, Iterator, Optional
5
+ from urllib.parse import quote
6
+
7
+ import httpx
8
+
9
+ from .types import Fill, FillCursor, FillsPage, Leaderboard, Subscription
10
+
11
+ DEFAULT_BASE_URL = "https://api.pmwallets.com"
12
+
13
+
14
+ class PmwError(Exception):
15
+ """A non-2xx answer from the API. `body` is the parsed JSON body when there was one."""
16
+
17
+ def __init__(self, status: int, body: Any, message: Optional[str] = None):
18
+ self.status = status
19
+ self.body = body
20
+ super().__init__(message or f"PMWallets API {status}: {body if isinstance(body, str) else json.dumps(body)}")
21
+
22
+
23
+ def _q(v: str) -> str:
24
+ return quote(v, safe="")
25
+
26
+
27
+ class _Base:
28
+ def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL, timeout: float = 15.0):
29
+ if not api_key:
30
+ raise ValueError("api_key is required")
31
+ self.api_key = api_key
32
+ self.base_url = base_url.rstrip("/")
33
+ self.timeout = timeout
34
+
35
+ @property
36
+ def ws_url(self) -> str:
37
+ """The WebSocket URL derived from the base URL (https → wss)."""
38
+ return ("ws" + self.base_url[4:] if self.base_url.startswith("http") else self.base_url) + "/v1/ws"
39
+
40
+ @property
41
+ def _headers(self) -> dict[str, str]:
42
+ return {"x-api-key": self.api_key, "accept": "application/json"}
43
+
44
+ @staticmethod
45
+ def _params(query: Optional[dict[str, Any]]) -> dict[str, Any]:
46
+ out: dict[str, Any] = {}
47
+ for k, v in (query or {}).items():
48
+ if v is None:
49
+ continue
50
+ out[k] = ("true" if v else "false") if isinstance(v, bool) else v
51
+ return out
52
+
53
+ @staticmethod
54
+ def _parse(res: httpx.Response) -> Any:
55
+ text = res.text
56
+ body: Any = text
57
+ if text:
58
+ try:
59
+ body = json.loads(text)
60
+ except ValueError:
61
+ pass
62
+ if res.status_code < 200 or res.status_code >= 300:
63
+ raise PmwError(res.status_code, body)
64
+ return body if text else None
65
+
66
+ @staticmethod
67
+ def _fills_query(since_block: int, since_log_index: int, limit: int) -> dict[str, Any]:
68
+ return {"sinceBlock": since_block, "sinceLogIndex": since_log_index, "limit": limit}
69
+
70
+
71
+ class Client(_Base):
72
+ """Synchronous REST client for https://api.pmwallets.com. Every method authenticates with the API key."""
73
+
74
+ def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL, timeout: float = 15.0, http: Optional[httpx.Client] = None):
75
+ super().__init__(api_key, base_url, timeout)
76
+ self._http = http or httpx.Client(timeout=timeout)
77
+
78
+ def close(self) -> None:
79
+ self._http.close()
80
+
81
+ def __enter__(self) -> "Client":
82
+ return self
83
+
84
+ def __exit__(self, *exc: Any) -> None:
85
+ self.close()
86
+
87
+ def request(self, method: str, path: str, query: Optional[dict[str, Any]] = None, body: Any = None) -> Any:
88
+ res = self._http.request(method, self.base_url + path, params=self._params(query), headers=self._headers,
89
+ json=body if body is not None else None)
90
+ return self._parse(res)
91
+
92
+ # ── the board
93
+ def leaderboard(self, **query: Any) -> Leaderboard:
94
+ return self.request("GET", "/v1/leaderboard", query)
95
+
96
+ def entity(self, entity_id: str, period: Optional[str] = None) -> dict[str, Any]:
97
+ """One entity by handle or address. The full address comes back only for entities you own."""
98
+ return self.request("GET", f"/v1/entities/{_q(entity_id)}", {"period": period})
99
+
100
+ def latency(self) -> dict[str, Any]:
101
+ return self.request("GET", "/v1/latency")
102
+
103
+ # ── addresses
104
+ def buy_reveal(self, entity_id: str, max_price_cents: int) -> dict[str, Any]:
105
+ """Buy the address behind a handle. `max_price_cents` is a ceiling: the charge never exceeds it."""
106
+ return self.request("POST", "/v1/account/reveals", body={"entityId": entity_id, "maxPriceCents": max_price_cents})
107
+
108
+ def reveals(self) -> list[dict[str, Any]]:
109
+ return self.request("GET", "/v1/account/reveals")
110
+
111
+ # ── subscriptions
112
+ def subscriptions(self) -> list[Subscription]:
113
+ """Active and paused subscriptions, newest first."""
114
+ return self.request("GET", "/v1/account/subscriptions")
115
+
116
+ def subscribe(self, entity_id: str, channels: Optional[list[str]] = None, accept_inactive: Optional[bool] = None) -> Subscription:
117
+ """Starts billing per hour. 402 = balance too low, 409 = dormant entity (resend with accept_inactive)."""
118
+ body: dict[str, Any] = {"channels": channels or ["ws"], "entityId": entity_id}
119
+ if accept_inactive is not None:
120
+ body["acceptInactive"] = accept_inactive
121
+ return self.request("POST", "/v1/account/subscriptions", body=body)
122
+
123
+ def cancel_subscription(self, sub_id: str) -> Any:
124
+ return self.request("DELETE", f"/v1/account/subscriptions/{_q(sub_id)}")
125
+
126
+ def resume_subscription(self, sub_id: str) -> Subscription:
127
+ """Charges another hour and resumes from the current head (the paused gap is not backfilled)."""
128
+ return self.request("POST", f"/v1/account/subscriptions/{_q(sub_id)}/resume")
129
+
130
+ # ── fills
131
+ def fills(self, since_block: int = 0, since_log_index: int = 0, limit: int = 500) -> FillsPage:
132
+ """One page of fills strictly after the cursor, oldest first (all active subscriptions)."""
133
+ return self.request("GET", "/v1/account/fills", self._fills_query(since_block, since_log_index, limit))
134
+
135
+ def fills_since(self, cursor: FillCursor, limit: int = 500) -> Iterator[Fill]:
136
+ """Every fill after the cursor, walking pages until the end."""
137
+ at: Optional[FillCursor] = cursor
138
+ while at:
139
+ page = self.fills(at["sinceBlock"], at["sinceLogIndex"], limit)
140
+ yield from page["rows"]
141
+ at = page["next"]
142
+
143
+ # ── trade-history exports
144
+ def export_quote(self, entity_ids: list[str], from_: str, to: str) -> dict[str, Any]:
145
+ return self.request("POST", "/v1/account/exports/quote", body={"entityIds": entity_ids, "from": from_, "to": to})
146
+
147
+ def create_export(self, entity_ids: list[str], from_: str, to: str) -> dict[str, Any]:
148
+ """Charges the balance; the price is recomputed server-side."""
149
+ return self.request("POST", "/v1/account/exports", body={"entityIds": entity_ids, "from": from_, "to": to})
150
+
151
+ def exports(self) -> list[dict[str, Any]]:
152
+ return self.request("GET", "/v1/account/exports")
153
+
154
+ def get_export(self, export_id: str) -> dict[str, Any]:
155
+ return self.request("GET", f"/v1/account/exports/{_q(export_id)}")
156
+
157
+ def export_download(self, export_id: str) -> dict[str, Any]:
158
+ """A presigned download URL, valid 15 minutes."""
159
+ return self.request("GET", f"/v1/account/exports/{_q(export_id)}/download")
160
+
161
+
162
+ class AsyncClient(_Base):
163
+ """asyncio twin of Client (the FillStream uses it for replays)."""
164
+
165
+ def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL, timeout: float = 15.0, http: Optional[httpx.AsyncClient] = None):
166
+ super().__init__(api_key, base_url, timeout)
167
+ self._http = http or httpx.AsyncClient(timeout=timeout)
168
+
169
+ async def aclose(self) -> None:
170
+ await self._http.aclose()
171
+
172
+ async def __aenter__(self) -> "AsyncClient":
173
+ return self
174
+
175
+ async def __aexit__(self, *exc: Any) -> None:
176
+ await self.aclose()
177
+
178
+ async def request(self, method: str, path: str, query: Optional[dict[str, Any]] = None, body: Any = None) -> Any:
179
+ res = await self._http.request(method, self.base_url + path, params=self._params(query), headers=self._headers,
180
+ json=body if body is not None else None)
181
+ return self._parse(res)
182
+
183
+ async def leaderboard(self, **query: Any) -> Leaderboard:
184
+ return await self.request("GET", "/v1/leaderboard", query)
185
+
186
+ async def entity(self, entity_id: str, period: Optional[str] = None) -> dict[str, Any]:
187
+ return await self.request("GET", f"/v1/entities/{_q(entity_id)}", {"period": period})
188
+
189
+ async def latency(self) -> dict[str, Any]:
190
+ return await self.request("GET", "/v1/latency")
191
+
192
+ async def buy_reveal(self, entity_id: str, max_price_cents: int) -> dict[str, Any]:
193
+ return await self.request("POST", "/v1/account/reveals", body={"entityId": entity_id, "maxPriceCents": max_price_cents})
194
+
195
+ async def reveals(self) -> list[dict[str, Any]]:
196
+ return await self.request("GET", "/v1/account/reveals")
197
+
198
+ async def subscriptions(self) -> list[Subscription]:
199
+ return await self.request("GET", "/v1/account/subscriptions")
200
+
201
+ async def subscribe(self, entity_id: str, channels: Optional[list[str]] = None, accept_inactive: Optional[bool] = None) -> Subscription:
202
+ body: dict[str, Any] = {"channels": channels or ["ws"], "entityId": entity_id}
203
+ if accept_inactive is not None:
204
+ body["acceptInactive"] = accept_inactive
205
+ return await self.request("POST", "/v1/account/subscriptions", body=body)
206
+
207
+ async def cancel_subscription(self, sub_id: str) -> Any:
208
+ return await self.request("DELETE", f"/v1/account/subscriptions/{_q(sub_id)}")
209
+
210
+ async def resume_subscription(self, sub_id: str) -> Subscription:
211
+ return await self.request("POST", f"/v1/account/subscriptions/{_q(sub_id)}/resume")
212
+
213
+ async def fills(self, since_block: int = 0, since_log_index: int = 0, limit: int = 500) -> FillsPage:
214
+ return await self.request("GET", "/v1/account/fills", self._fills_query(since_block, since_log_index, limit))
215
+
216
+ async def fills_since(self, cursor: FillCursor, limit: int = 500) -> AsyncIterator[Fill]:
217
+ at: Optional[FillCursor] = cursor
218
+ while at:
219
+ page = await self.fills(at["sinceBlock"], at["sinceLogIndex"], limit)
220
+ for row in page["rows"]:
221
+ yield row
222
+ at = page["next"]
223
+
224
+ async def export_quote(self, entity_ids: list[str], from_: str, to: str) -> dict[str, Any]:
225
+ return await self.request("POST", "/v1/account/exports/quote", body={"entityIds": entity_ids, "from": from_, "to": to})
226
+
227
+ async def create_export(self, entity_ids: list[str], from_: str, to: str) -> dict[str, Any]:
228
+ return await self.request("POST", "/v1/account/exports", body={"entityIds": entity_ids, "from": from_, "to": to})
229
+
230
+ async def exports(self) -> list[dict[str, Any]]:
231
+ return await self.request("GET", "/v1/account/exports")
232
+
233
+ async def get_export(self, export_id: str) -> dict[str, Any]:
234
+ return await self.request("GET", f"/v1/account/exports/{_q(export_id)}")
235
+
236
+ async def export_download(self, export_id: str) -> dict[str, Any]:
237
+ return await self.request("GET", f"/v1/account/exports/{_q(export_id)}/download")
@@ -0,0 +1,324 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import json
6
+ import math
7
+ import os
8
+ from collections import OrderedDict
9
+ from dataclasses import asdict, dataclass
10
+ from pathlib import Path
11
+ from typing import Any, AsyncContextManager, Awaitable, Callable, Optional, Protocol, Union
12
+
13
+ from .client import AsyncClient
14
+ from .types import Fill
15
+
16
+
17
+ @dataclass
18
+ class StreamState:
19
+ """The WebSocket numbering last accepted and the ledger position of the last delivered fill.
20
+ Persist it (FileStateStore) and a restart resumes exactly where it stopped."""
21
+
22
+ session: Optional[str] = None
23
+ seq: int = 0
24
+ block: int = 0
25
+ logIndex: int = 0
26
+
27
+
28
+ class StateStore(Protocol):
29
+ async def load(self) -> Optional[StreamState]: ...
30
+ async def save(self, state: StreamState) -> None: ...
31
+
32
+
33
+ class MemoryStateStore:
34
+ def __init__(self) -> None:
35
+ self._state: Optional[StreamState] = None
36
+
37
+ async def load(self) -> Optional[StreamState]:
38
+ return StreamState(**asdict(self._state)) if self._state else None
39
+
40
+ async def save(self, state: StreamState) -> None:
41
+ self._state = StreamState(**asdict(state))
42
+
43
+
44
+ class FileStateStore:
45
+ """JSON file, replaced atomically (temp file + os.replace) so a crash never leaves half a file.
46
+ Same format as the Node SDK's FileStateStore — the two are interchangeable."""
47
+
48
+ def __init__(self, path: Union[str, Path]) -> None:
49
+ self.path = Path(path)
50
+
51
+ async def load(self) -> Optional[StreamState]:
52
+ try:
53
+ s = json.loads(self.path.read_text("utf8"))
54
+ except FileNotFoundError:
55
+ return None
56
+ return StreamState(session=s.get("session"), seq=s.get("seq", 0), block=s.get("block", 0), logIndex=s.get("logIndex", 0))
57
+
58
+ async def save(self, state: StreamState) -> None:
59
+ self.path.parent.mkdir(parents=True, exist_ok=True)
60
+ tmp = self.path.with_name(self.path.name + ".tmp")
61
+ tmp.write_text(json.dumps(asdict(state), separators=(",", ":")))
62
+ os.replace(tmp, self.path)
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class FillMeta:
67
+ source: str # "ws" | "replay"
68
+
69
+
70
+ class UpgradeRefused(Exception):
71
+ """The server answered the WebSocket upgrade with an HTTP status instead of 101."""
72
+
73
+ def __init__(self, status_code: int):
74
+ super().__init__(f"WebSocket upgrade refused with {status_code}")
75
+ self.status_code = status_code
76
+
77
+
78
+ class SocketLike(Protocol):
79
+ close_code: Optional[int]
80
+ close_reason: Optional[str]
81
+
82
+ def __aiter__(self) -> Any: ...
83
+ async def close(self, code: int = 1000, reason: str = "") -> None: ...
84
+
85
+
86
+ Connector = Callable[[str, dict[str, str]], AsyncContextManager[SocketLike]]
87
+ OnFill = Callable[[Fill, FillMeta], Union[None, Awaitable[None]]]
88
+ OnEvent = Callable[[dict[str, Any]], None]
89
+
90
+
91
+ def websockets_connector(ping_interval: float = 20.0, **ws_options: Any) -> Connector:
92
+ """The real transport: `websockets` asyncio client. websockets>=15 picks up HTTPS_PROXY by itself."""
93
+ from websockets.asyncio.client import connect
94
+ from websockets.exceptions import InvalidStatus
95
+
96
+ @contextlib.asynccontextmanager
97
+ async def _connect(url: str, headers: dict[str, str]):
98
+ try:
99
+ cm = connect(url, additional_headers=headers, ping_interval=ping_interval, ping_timeout=ping_interval, **ws_options)
100
+ ws = await cm.__aenter__()
101
+ except InvalidStatus as e:
102
+ raise UpgradeRefused(e.response.status_code) from e
103
+ try:
104
+ yield ws
105
+ finally:
106
+ await cm.__aexit__(None, None, None)
107
+
108
+ return _connect
109
+
110
+
111
+ class FillStream:
112
+ """The fills of every entity your account subscribes to, delivered exactly once and in order.
113
+
114
+ The WebSocket is best effort: a frame dropped for a slow consumer, or everything sent while you were
115
+ reconnecting, is not resent. Every frame carries `session` + a consecutive `seq`, so a skip is
116
+ detectable — and on any skip or new session this stream pulls the gap from GET /v1/account/fills
117
+ (keyset-paged from the last delivered fill) before it moves on. Duplicates are dropped by eventId.
118
+
119
+ `on_fill(fill, meta)` is called once per fill, in ledger order, never concurrently. A fill counts as
120
+ delivered only after it returns: if it raises, the connection is dropped and the fill is offered again
121
+ after the reconnect's replay — make it idempotent on eventId, or never raise.
122
+ """
123
+
124
+ def __init__(
125
+ self,
126
+ client: AsyncClient,
127
+ on_fill: OnFill,
128
+ on_event: Optional[OnEvent] = None,
129
+ store: Optional[StateStore] = None,
130
+ ping_interval: float = 20.0,
131
+ min_backoff: float = 1.0,
132
+ max_backoff: float = 30.0,
133
+ seen_capacity: int = 10_000,
134
+ connector: Optional[Connector] = None,
135
+ ws_options: Optional[dict[str, Any]] = None,
136
+ replay_without_cursor: bool = False,
137
+ anchor_lag_blocks: int = 200,
138
+ ) -> None:
139
+ self.client = client
140
+ self.on_fill = on_fill
141
+ self.on_event = on_event
142
+ self.store: StateStore = store or MemoryStateStore()
143
+ self.min_backoff = min_backoff
144
+ self.max_backoff = max_backoff
145
+ self.seen_capacity = seen_capacity
146
+ # Where to start when there is no saved position. Default False: at the first connection the cursor
147
+ # is anchored at the current chain head, so a disconnect before the first fill is still replayed —
148
+ # but the history from before you started is not. True: start from zero and receive every fill
149
+ # since each subscription began.
150
+ self.replay_without_cursor = replay_without_cursor
151
+ # How far behind the chain head to anchor (~5 min). The head can run ahead of the fills already indexed for
152
+ # your account; anchoring exactly at it could exclude a fill mined earlier but not yet pushed. The extra
153
+ # blocks are replayed at most once more and dropped by eventId.
154
+ self.anchor_lag_blocks = anchor_lag_blocks
155
+ self.connector = connector or websockets_connector(ping_interval, **(ws_options or {}))
156
+ self.state = StreamState()
157
+ self._seen: "OrderedDict[str, bool]" = OrderedDict()
158
+ self._running = False
159
+ self._task: Optional[asyncio.Task[None]] = None
160
+ self._socket: Optional[SocketLike] = None
161
+ self._wake = asyncio.Event()
162
+
163
+ @property
164
+ def position(self) -> StreamState:
165
+ return StreamState(**asdict(self.state))
166
+
167
+ async def start(self) -> None:
168
+ """Loads the saved state and starts connecting. Returns immediately; call stop() to end."""
169
+ if self._running:
170
+ return
171
+ self.state = (await self.store.load()) or self.state
172
+ self._running = True
173
+ self._task = asyncio.create_task(self._loop())
174
+
175
+ async def stop(self) -> None:
176
+ """Closes the socket and waits for the fill being handled (if any) to finish."""
177
+ self._running = False
178
+ self._wake.set()
179
+ if self._socket is not None:
180
+ with contextlib.suppress(Exception):
181
+ await self._socket.close(1000)
182
+ if self._task is not None:
183
+ with contextlib.suppress(asyncio.CancelledError):
184
+ await self._task
185
+
186
+ async def run(self) -> None:
187
+ """start() and block until the stream stops."""
188
+ await self.start()
189
+ await self.wait()
190
+
191
+ async def wait(self) -> None:
192
+ """Block until the stream stops (stop() or a fatal error)."""
193
+ if self._task is not None:
194
+ with contextlib.suppress(asyncio.CancelledError):
195
+ await self._task
196
+
197
+ def _emit(self, event: dict[str, Any]) -> None:
198
+ if self.on_event is None:
199
+ return
200
+ with contextlib.suppress(Exception): # a listener must not break the stream
201
+ self.on_event(event)
202
+
203
+ async def _loop(self) -> None:
204
+ backoff = self.min_backoff
205
+ while self._running:
206
+ healthy = await self._connect_once()
207
+ if not self._running:
208
+ break
209
+ backoff = self.min_backoff if healthy else min(backoff * 2, self.max_backoff)
210
+ self._wake.clear()
211
+ with contextlib.suppress(asyncio.TimeoutError):
212
+ await asyncio.wait_for(self._wake.wait(), backoff)
213
+
214
+ async def _connect_once(self) -> bool:
215
+ """One connection's life; True when it got as far as a hello and did not fail."""
216
+ url = self.client.ws_url
217
+ self._emit({"type": "connecting", "url": url})
218
+ greeted = False
219
+ failed = False
220
+ code, reason = 1006, ""
221
+ try:
222
+ async with self.connector(url, {"x-api-key": self.client.api_key}) as ws:
223
+ self._socket = ws
224
+ self._emit({"type": "connected"})
225
+ try:
226
+ async for raw in ws:
227
+ try:
228
+ frame = json.loads(raw)
229
+ except (ValueError, TypeError):
230
+ continue
231
+ if not isinstance(frame, dict):
232
+ continue
233
+ try:
234
+ await self._handle_frame(frame)
235
+ except Exception as e: # leave the position where it is; the next hello replays from it
236
+ failed = True
237
+ self._emit({"type": "error", "error": e})
238
+ break
239
+ if frame.get("type") == "hello":
240
+ greeted = True
241
+ except Exception as e: # ConnectionClosedError and friends
242
+ self._emit({"type": "error", "error": e})
243
+ code = getattr(ws, "close_code", None) or 1006
244
+ reason = getattr(ws, "close_reason", None) or ""
245
+ except UpgradeRefused as e:
246
+ if e.status_code in (401, 403):
247
+ self._running = False
248
+ self._emit({"type": "fatal", "error": RuntimeError(f"WebSocket upgrade refused with {e.status_code}: check the API key")})
249
+ else:
250
+ self._emit({"type": "error", "error": e})
251
+ return False
252
+ except Exception as e:
253
+ self._emit({"type": "error", "error": e})
254
+ return False
255
+ finally:
256
+ self._socket = None
257
+ if code == 1000 and "replaced" in reason.lower():
258
+ self._emit({"type": "replaced"})
259
+ self._emit({"type": "disconnected", "code": code, "reason": reason})
260
+ return greeted and not failed
261
+
262
+ async def _handle_frame(self, m: dict[str, Any]) -> None:
263
+ if m.get("type") == "hello" and isinstance(m.get("session"), str):
264
+ # A new session means the socket (or this process) was down: replay BEFORE adopting it —
265
+ # adopting first is what silently swallows an outage.
266
+ if self.state.block == 0 and not self.replay_without_cursor:
267
+ await self._anchor()
268
+ elif self.state.session is not None and m["session"] != self.state.session:
269
+ await self._replay("new_session")
270
+ self.state.session = m["session"]
271
+ self.state.seq = int(m.get("seq") or 0)
272
+ await self.store.save(self.state)
273
+ self._emit({"type": "hello", "session": m["session"], "seq": self.state.seq})
274
+ return
275
+ if m.get("type") != "fill" or not m.get("data") or not isinstance(m.get("seq"), int):
276
+ return
277
+ if m.get("session") != self.state.session or m["seq"] != self.state.seq + 1:
278
+ await self._replay("seq_skip")
279
+ await self._deliver(m["data"], "ws")
280
+ # only once the fill is handled: a position that ran ahead of a failed delivery would hide the gap
281
+ self.state.session = m.get("session")
282
+ self.state.seq = m["seq"]
283
+ await self.store.save(self.state)
284
+
285
+ async def _anchor(self) -> None:
286
+ """No position yet: take the chain head as the starting point. Without it a disconnect before the first
287
+ fill could not be replayed, and replaying from zero would hand over every fill since each subscription began."""
288
+ r = await self.client.latency()
289
+ raw = (r.get("head") or {}).get("block") if isinstance(r, dict) else None
290
+ try:
291
+ head = float(raw) if raw is not None and not isinstance(raw, bool) else math.nan
292
+ except (TypeError, ValueError):
293
+ head = math.nan
294
+ if not (math.isfinite(head) and head == int(head) and head > 0):
295
+ raise RuntimeError("could not read the chain head to anchor the stream")
296
+ head = int(head)
297
+ # strictly-after semantics: everything from (head − lag) on; never 0, which means "no position"
298
+ self.state.block = max(1, head - self.anchor_lag_blocks - 1)
299
+ self.state.logIndex = 0xFFFFFFFF
300
+ self._emit({"type": "anchored", "block": self.state.block + 1})
301
+
302
+ async def _replay(self, reason: str) -> None:
303
+ self._emit({"type": "gap", "reason": reason, "fromBlock": self.state.block, "fromLogIndex": self.state.logIndex})
304
+ delivered = 0
305
+ async for fill in self.client.fills_since({"sinceBlock": self.state.block, "sinceLogIndex": self.state.logIndex}):
306
+ if await self._deliver(fill, "replay"):
307
+ delivered += 1
308
+ await self.store.save(self.state)
309
+ self._emit({"type": "replayed", "delivered": delivered})
310
+
311
+ async def _deliver(self, fill: Fill, source: str) -> bool:
312
+ if fill["eventId"] in self._seen:
313
+ return False
314
+ r = self.on_fill(fill, FillMeta(source))
315
+ if asyncio.iscoroutine(r) or isinstance(r, asyncio.Future):
316
+ await r
317
+ self._seen[fill["eventId"]] = True
318
+ if len(self._seen) > self.seen_capacity:
319
+ self._seen.popitem(last=False)
320
+ # never move backwards: a live frame can be older than what the replay just walked past
321
+ if fill["block"] > self.state.block or (fill["block"] == self.state.block and fill["logIndex"] > self.state.logIndex):
322
+ self.state.block = fill["block"]
323
+ self.state.logIndex = fill["logIndex"]
324
+ return True
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Literal, Optional, TypedDict
4
+
5
+
6
+ class Fill(TypedDict):
7
+ """One fill of an entity you subscribe to, exactly as the WebSocket frame and GET /v1/account/fills carry it."""
8
+
9
+ eventId: str # chain:block:blockHash:txHash:logIndex — deduplicate on it
10
+ chain: int
11
+ entityId: str # the subscribed entity (0x address)
12
+ wallet: str # the address inside the entity that traded
13
+ ts: str # block time, UTC, "YYYY-MM-DD HH:MM:SS"
14
+ block: int
15
+ blockHash: str
16
+ txHash: str
17
+ logIndex: int
18
+ exchange: str
19
+ side: Literal["BUY", "SELL"]
20
+ role: Literal["maker", "taker"]
21
+ tokenId: str # Polymarket outcome token id (uint256, decimal string)
22
+ price: str # decimal string, e.g. "0.570000"
23
+ shares: str # integer string, 1e-6 share units
24
+ usdc: str # integer string, 1e-6 USDC units
25
+ fee: str # integer string, 1e-6 USDC units
26
+
27
+
28
+ class FillCursor(TypedDict):
29
+ sinceBlock: int
30
+ sinceLogIndex: int
31
+
32
+
33
+ class FillsPage(TypedDict):
34
+ rows: list[Fill]
35
+ next: Optional[FillCursor]
36
+ subscriptions: int
37
+
38
+
39
+ Subscription = dict[str, Any]
40
+ Leaderboard = dict[str, Any]
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import re
6
+ from typing import Optional, Union
7
+
8
+ _HEX = re.compile(r"^[0-9a-fA-F]+$")
9
+
10
+
11
+ def verify_webhook(raw_body: Union[bytes, str], signature_hex: Optional[str], secret: str) -> bool:
12
+ """Verify `x-pmw-signature`: hex HMAC-SHA256 of the RAW request body, keyed with your webhook secret.
13
+
14
+ Pass the raw bytes, before any JSON parsing — re-serialising changes them and every check fails.
15
+ """
16
+ if not signature_hex or not secret or not _HEX.match(signature_hex) or len(signature_hex) % 2:
17
+ return False
18
+ body = raw_body.encode() if isinstance(raw_body, str) else raw_body
19
+ mine = hmac.new(secret.encode(), body, hashlib.sha256).digest()
20
+ return hmac.compare_digest(bytes.fromhex(signature_hex), mine)
@@ -0,0 +1,58 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from pmwallets import AsyncClient, Client, PmwError, verify_webhook
9
+
10
+
11
+ def transport(pages, status=200, calls=None):
12
+ def handler(req: httpx.Request):
13
+ if calls is not None:
14
+ calls.append(req)
15
+ body = pages.pop(0) if pages else None
16
+ return httpx.Response(status, text="" if body is None else json.dumps(body))
17
+ return httpx.MockTransport(handler)
18
+
19
+
20
+ def test_sends_key_builds_query_and_ws_url():
21
+ calls = []
22
+ c = Client("pmw_a_b", "https://api.example.com/", http=httpx.Client(transport=transport([{"rows": [], "next": None, "subscriptions": 0}], calls=calls)))
23
+ c.fills(5, 2)
24
+ assert str(calls[0].url) == "https://api.example.com/v1/account/fills?sinceBlock=5&sinceLogIndex=2&limit=500"
25
+ assert calls[0].headers["x-api-key"] == "pmw_a_b"
26
+ assert c.ws_url == "wss://api.example.com/v1/ws"
27
+
28
+
29
+ async def test_walks_every_page():
30
+ calls = []
31
+ pages = [
32
+ {"rows": [{"eventId": "a"}, {"eventId": "b"}], "next": {"sinceBlock": 2, "sinceLogIndex": 0}, "subscriptions": 1},
33
+ {"rows": [{"eventId": "c"}], "next": None, "subscriptions": 1},
34
+ ]
35
+ c = AsyncClient("k", http=httpx.AsyncClient(transport=transport(pages, calls=calls)))
36
+ ids = [r["eventId"] async for r in c.fills_since({"sinceBlock": 0, "sinceLogIndex": 0}, 2)]
37
+ assert ids == ["a", "b", "c"]
38
+ assert "sinceBlock=2&sinceLogIndex=0&limit=2" in str(calls[1].url)
39
+
40
+
41
+ def test_non_2xx_is_pmw_error():
42
+ calls = []
43
+ c = Client("k", http=httpx.Client(transport=transport([{"statusCode": 402, "message": "insufficient balance"}], 402, calls)))
44
+ with pytest.raises(PmwError) as e:
45
+ c.subscribe("0xabc")
46
+ assert e.value.status == 402 and e.value.body["message"] == "insufficient balance"
47
+ assert json.loads(calls[0].content) == {"channels": ["ws"], "entityId": "0xabc"}
48
+
49
+
50
+ def test_verify_webhook():
51
+ body = '{"type":"fill","data":{}}'
52
+ sig = hmac.new(b"sec", body.encode(), hashlib.sha256).hexdigest()
53
+ assert verify_webhook(body, sig, "sec")
54
+ assert verify_webhook(body.encode(), sig, "sec")
55
+ assert not verify_webhook(body + " ", sig, "sec")
56
+ assert not verify_webhook(body, sig, "other")
57
+ assert not verify_webhook(body, "zz", "sec")
58
+ assert not verify_webhook(body, None, "sec")
@@ -0,0 +1,53 @@
1
+ import asyncio
2
+ import json
3
+
4
+ import httpx
5
+
6
+ from websockets.asyncio.server import serve
7
+ from websockets.http11 import Response
8
+ from websockets.datastructures import Headers
9
+
10
+ from pmwallets import AsyncClient, FillStream
11
+
12
+
13
+ async def _server():
14
+ def process_request(conn, request):
15
+ if request.path.startswith("/v1/latency"):
16
+ return Response(200, "OK", Headers([("Content-Type", "application/json")]), b'{"head":{"block":1}}')
17
+ if request.headers.get("x-api-key") != "good":
18
+ return Response(401, "Unauthorized", Headers([("Content-Length", "0")]), b"")
19
+ return None
20
+
21
+ async def handler(ws):
22
+ await ws.send(json.dumps({"type": "hello", "userId": "u", "session": "S", "seq": 0}))
23
+ await ws.send(json.dumps({"type": "fill", "session": "S", "seq": 1, "data": {"eventId": "e1", "block": 2, "logIndex": 0}}))
24
+ await ws.wait_closed()
25
+
26
+ server = await serve(handler, "127.0.0.1", 0, process_request=process_request)
27
+ port = server.sockets[0].getsockname()[1]
28
+ return server, f"http://127.0.0.1:{port}"
29
+
30
+
31
+ async def test_receives_frames_with_a_good_key():
32
+ server, base = await _server()
33
+ got = []
34
+ stream = FillStream(AsyncClient("good", base, http=httpx.AsyncClient(trust_env=False)), lambda f, m: got.append(f["eventId"]), ws_options={"proxy": None})
35
+ await stream.start()
36
+ for _ in range(50):
37
+ if got:
38
+ break
39
+ await asyncio.sleep(0.02)
40
+ await stream.stop()
41
+ server.close()
42
+ assert got == ["e1"]
43
+
44
+
45
+ async def test_fatal_on_401():
46
+ server, base = await _server()
47
+ events = []
48
+ stream = FillStream(AsyncClient("bad", base), lambda f, m: None, on_event=events.append, min_backoff=0.005, ws_options={"proxy": None})
49
+ await stream.start()
50
+ await asyncio.wait_for(stream.wait(), 2)
51
+ server.close()
52
+ types = [e["type"] for e in events]
53
+ assert types.count("fatal") == 1 and types.count("connecting") == 1
@@ -0,0 +1,207 @@
1
+ import asyncio
2
+ import contextlib
3
+ import json
4
+
5
+ from pmwallets.stream import FileStateStore, FillStream, MemoryStateStore, UpgradeRefused
6
+
7
+
8
+ def fill(block, log_index):
9
+ return {
10
+ "eventId": f"137:{block}:0xh:0xt{block}:{log_index}", "chain": 137, "entityId": "0xe", "wallet": "0xw",
11
+ "ts": "2026-09-24 00:00:00", "block": block, "blockHash": "0xh", "txHash": f"0xt{block}", "logIndex": log_index,
12
+ "exchange": "pm_ctf_v2", "side": "BUY", "role": "taker", "tokenId": "1", "price": "0.5", "shares": "1000000",
13
+ "usdc": "500000", "fee": "0",
14
+ }
15
+
16
+
17
+ _END = object()
18
+
19
+
20
+ class FakeSocket:
21
+ """a scripted socket: the test pushes frames and closes it"""
22
+
23
+ def __init__(self):
24
+ self.q: asyncio.Queue = asyncio.Queue()
25
+ self.close_code = None
26
+ self.close_reason = None
27
+ self.closed = False
28
+
29
+ def send(self, frame):
30
+ self.q.put_nowait(json.dumps(frame))
31
+
32
+ def remote_close(self, code=1006, reason=""):
33
+ self.close_code, self.close_reason = code, reason
34
+ self.q.put_nowait(_END)
35
+
36
+ async def close(self, code=1000, reason=""):
37
+ self.closed = True
38
+ if self.close_code is None:
39
+ self.close_code, self.close_reason = code, reason
40
+ self.q.put_nowait(_END)
41
+
42
+ def __aiter__(self):
43
+ return self
44
+
45
+ async def __anext__(self):
46
+ item = await self.q.get()
47
+ if item is _END:
48
+ raise StopAsyncIteration
49
+ return item
50
+
51
+
52
+ class FakeClient:
53
+ api_key = "pmw_x_y"
54
+ ws_url = "wss://example/v1/ws"
55
+
56
+ def __init__(self, ledger, head=209):
57
+ self.ledger = ledger
58
+ self.replays = []
59
+ self.head = head
60
+
61
+ async def latency(self):
62
+ return {"head": {"block": self.head}}
63
+
64
+ async def fills_since(self, c):
65
+ self.replays.append(dict(c))
66
+ for f in self.ledger:
67
+ if f["block"] > c["sinceBlock"] or (f["block"] == c["sinceBlock"] and f["logIndex"] > c["sinceLogIndex"]):
68
+ yield f
69
+
70
+
71
+ def harness(ledger, fail_on=None, refuse=None, head=209):
72
+ sockets, delivered, events = [], [], []
73
+ client = FakeClient(ledger, head)
74
+ state = {"fail": fail_on}
75
+
76
+ @contextlib.asynccontextmanager
77
+ async def connector(url, headers):
78
+ if refuse:
79
+ raise UpgradeRefused(refuse)
80
+ s = FakeSocket()
81
+ sockets.append(s)
82
+ try:
83
+ yield s
84
+ finally:
85
+ s.closed = True
86
+
87
+ def on_fill(f, meta):
88
+ if state["fail"] == f["eventId"]:
89
+ state["fail"] = None
90
+ raise RuntimeError("handler failed")
91
+ delivered.append((f["eventId"], meta.source))
92
+
93
+ stream = FillStream(client, on_fill, on_event=events.append, store=MemoryStateStore(), min_backoff=0.001,
94
+ max_backoff=0.002, connector=connector)
95
+ return stream, sockets, client, delivered, events
96
+
97
+
98
+ async def tick():
99
+ await asyncio.sleep(0.02)
100
+
101
+
102
+ async def test_delivers_consecutive_frames_in_order():
103
+ stream, sockets, client, delivered, _ = harness([])
104
+ await stream.start(); await tick()
105
+ s = sockets[0]
106
+ s.send({"type": "hello", "session": "A", "seq": 0})
107
+ s.send({"type": "fill", "session": "A", "seq": 1, "data": fill(10, 1)})
108
+ s.send({"type": "fill", "session": "A", "seq": 2, "data": fill(10, 2)})
109
+ await tick()
110
+ assert [d[0] for d in delivered] == [fill(10, 1)["eventId"], fill(10, 2)["eventId"]]
111
+ assert client.replays == []
112
+ p = stream.position
113
+ assert (p.session, p.seq, p.block, p.logIndex) == ("A", 2, 10, 2)
114
+ await stream.stop()
115
+
116
+
117
+ async def test_seq_skip_replays_and_drops_duplicate():
118
+ ledger = [fill(10, 1), fill(11, 1), fill(12, 1)]
119
+ stream, sockets, client, delivered, _ = harness(ledger)
120
+ await stream.start(); await tick()
121
+ s = sockets[0]
122
+ s.send({"type": "hello", "session": "A", "seq": 0})
123
+ s.send({"type": "fill", "session": "A", "seq": 1, "data": ledger[0]})
124
+ s.send({"type": "fill", "session": "A", "seq": 3, "data": ledger[2]})
125
+ await tick()
126
+ assert client.replays == [{"sinceBlock": 10, "sinceLogIndex": 1}]
127
+ assert delivered == [(ledger[0]["eventId"], "ws"), (ledger[1]["eventId"], "replay"), (ledger[2]["eventId"], "replay")]
128
+ assert stream.position.seq == 3 and stream.position.block == 12
129
+ await stream.stop()
130
+
131
+
132
+ async def test_new_session_replays_what_was_missed():
133
+ ledger = [fill(10, 1), fill(11, 1)]
134
+ stream, sockets, client, delivered, _ = harness(ledger)
135
+ await stream.start(); await tick()
136
+ sockets[0].send({"type": "hello", "session": "A", "seq": 0})
137
+ sockets[0].send({"type": "fill", "session": "A", "seq": 1, "data": ledger[0]})
138
+ await tick()
139
+ sockets[0].remote_close(1006)
140
+ await tick()
141
+ sockets[1].send({"type": "hello", "session": "B", "seq": 0})
142
+ await tick()
143
+ assert client.replays == [{"sinceBlock": 10, "sinceLogIndex": 1}]
144
+ assert [d[0] for d in delivered] == [ledger[0]["eventId"], ledger[1]["eventId"]]
145
+ assert stream.position.session == "B"
146
+ await stream.stop()
147
+
148
+
149
+ async def test_throwing_handler_is_redelivered_after_reconnect():
150
+ ledger = [fill(10, 1), fill(11, 1)]
151
+ stream, sockets, client, delivered, _ = harness(ledger, fail_on=ledger[1]["eventId"])
152
+ await stream.start(); await tick()
153
+ s = sockets[0]
154
+ s.send({"type": "hello", "session": "A", "seq": 0})
155
+ s.send({"type": "fill", "session": "A", "seq": 1, "data": ledger[0]})
156
+ s.send({"type": "fill", "session": "A", "seq": 2, "data": ledger[1]})
157
+ await tick()
158
+ assert s.closed
159
+ assert stream.position.seq == 1 and stream.position.block == 10
160
+ sockets[1].send({"type": "hello", "session": "B", "seq": 0})
161
+ await tick()
162
+ assert [d[0] for d in delivered] == [ledger[0]["eventId"], ledger[1]["eventId"]]
163
+ await stream.stop()
164
+
165
+
166
+ async def test_401_is_fatal():
167
+ stream, sockets, _, _, events = harness([], refuse=401)
168
+ await stream.start()
169
+ await asyncio.wait_for(stream.wait(), 1)
170
+ assert [e["type"] for e in events].count("fatal") == 1
171
+ assert [e["type"] for e in events].count("connecting") == 1
172
+
173
+
174
+ async def test_reports_takeover():
175
+ stream, sockets, _, _, events = harness([])
176
+ await stream.start(); await tick()
177
+ sockets[0].remote_close(1000, "replaced by a newer connection")
178
+ await tick()
179
+ assert any(e["type"] == "replaced" for e in events)
180
+ await stream.stop()
181
+
182
+
183
+ async def test_anchors_behind_the_chain_head_on_first_hello():
184
+ # head 300, lag 200 → replay covers blocks ≥ 100. Block 99 is history; block 150 was mined before we connected
185
+ # but had not been pushed yet (the head ran ahead of the fill index).
186
+ ledger = [fill(99, 3), fill(150, 1), fill(301, 1)]
187
+ stream, sockets, client, delivered, events = harness(ledger, head=300)
188
+ await stream.start(); await tick()
189
+ sockets[0].send({"type": "hello", "session": "A", "seq": 0})
190
+ await tick()
191
+ assert (stream.position.block, stream.position.logIndex) == (99, 0xFFFFFFFF)
192
+ assert {"type": "anchored", "block": 100} in events
193
+ sockets[0].remote_close(1006); await tick()
194
+ sockets[1].send({"type": "hello", "session": "B", "seq": 0})
195
+ await tick()
196
+ assert client.replays == [{"sinceBlock": 99, "sinceLogIndex": 0xFFFFFFFF}]
197
+ assert [d[0] for d in delivered] == [ledger[1]["eventId"], ledger[2]["eventId"]]
198
+ await stream.stop()
199
+
200
+
201
+ async def test_file_state_store_roundtrip(tmp_path):
202
+ from pmwallets.stream import StreamState
203
+ store = FileStateStore(tmp_path / "sub" / "state.json")
204
+ assert await store.load() is None
205
+ await store.save(StreamState("s", 3, 9, 2))
206
+ assert await store.load() == StreamState("s", 3, 9, 2)
207
+ assert json.loads((tmp_path / "sub" / "state.json").read_text()) == {"session": "s", "seq": 3, "block": 9, "logIndex": 2}