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.
- pmwallets-0.1.0/.github/workflows/ci.yml +18 -0
- pmwallets-0.1.0/.github/workflows/publish.yml +24 -0
- pmwallets-0.1.0/.gitignore +9 -0
- pmwallets-0.1.0/LICENSE +21 -0
- pmwallets-0.1.0/PKG-INFO +84 -0
- pmwallets-0.1.0/README.md +66 -0
- pmwallets-0.1.0/README.zh.md +41 -0
- pmwallets-0.1.0/pyproject.toml +28 -0
- pmwallets-0.1.0/src/pmwallets/__init__.py +13 -0
- pmwallets-0.1.0/src/pmwallets/client.py +237 -0
- pmwallets-0.1.0/src/pmwallets/stream.py +324 -0
- pmwallets-0.1.0/src/pmwallets/types.py +40 -0
- pmwallets-0.1.0/src/pmwallets/webhook.py +20 -0
- pmwallets-0.1.0/tests/test_client.py +58 -0
- pmwallets-0.1.0/tests/test_real_socket.py +53 -0
- pmwallets-0.1.0/tests/test_stream.py +207 -0
|
@@ -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
|
pmwallets-0.1.0/LICENSE
ADDED
|
@@ -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.
|
pmwallets-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/pmwallets/) [](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
|
+
[](https://pypi.org/project/pmwallets/) [](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
|
+
[](https://pypi.org/project/pmwallets/) [](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}
|