hashlock-sdk 0.4.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.
- hashlock_sdk-0.4.0/.gitignore +9 -0
- hashlock_sdk-0.4.0/PKG-INFO +56 -0
- hashlock_sdk-0.4.0/README.md +36 -0
- hashlock_sdk-0.4.0/hashlock/__init__.py +8 -0
- hashlock_sdk-0.4.0/hashlock/client.py +183 -0
- hashlock_sdk-0.4.0/hashlock/errors.py +20 -0
- hashlock_sdk-0.4.0/hashlock/secret.py +21 -0
- hashlock_sdk-0.4.0/hashlock/webhooks.py +36 -0
- hashlock_sdk-0.4.0/hashlock/ws.py +41 -0
- hashlock_sdk-0.4.0/pyproject.toml +31 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hashlock-sdk
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Python SDK for the Hashlock Markets developer API — non-custodial cross-chain atomic swaps (BTC <-> EVM/TRON) over sealed RFQ + HTLC.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Hashlock-Tech/hashlock-sdk
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: atomic-swap,bitcoin,cross-chain,defi,hashlock,htlc,otc
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Intended Audience :: Financial and Insurance Industry
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Requires-Dist: httpx>=0.24
|
|
17
|
+
Provides-Extra: ws
|
|
18
|
+
Requires-Dist: websockets>=12; extra == 'ws'
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# hashlock-sdk (Python)
|
|
22
|
+
|
|
23
|
+
Python SDK for the [Hashlock Markets](https://github.com/Hashlock-Tech/hashlock-sdk) developer API —
|
|
24
|
+
non-custodial cross-chain atomic swaps (BTC ↔ EVM/TRON). Python 3.9+, built on `httpx`.
|
|
25
|
+
|
|
26
|
+
> ⚠️ **Sandbox / testnet preview (0.x)** — defaults to `api-dev.hashlock.markets`, testnets only,
|
|
27
|
+
> pre-mainnet. The API may change before the stable `1.0`. Not for mainnet funds.
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install hashlock-sdk # add [ws] for the maker WebSocket feed: hashlock-sdk[ws]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from hashlock import HashlockClient, new_secret, verify_webhook
|
|
35
|
+
|
|
36
|
+
client = HashlockClient(api_key=os.environ["HASHLOCK_API_KEY"])
|
|
37
|
+
client.me()
|
|
38
|
+
|
|
39
|
+
# cursor pagination — page or auto-iterate
|
|
40
|
+
page = client.list_swaps(limit=20)
|
|
41
|
+
for swap in client.swaps():
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
# initiator: secret stays private; submit only the hashlock
|
|
45
|
+
secret, hashlock = new_secret()
|
|
46
|
+
client.accept_terms(thread_id, hashlock=hashlock)
|
|
47
|
+
|
|
48
|
+
# webhooks: verify a delivery against the raw body
|
|
49
|
+
ok = verify_webhook(secret, raw_body, request.headers.get("x-hashlock-signature"), request.headers.get("x-hashlock-timestamp"))
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Settlement is custody-agnostic: `build_fund` / `build_claim` / `build_refund` return unsigned transactions —
|
|
53
|
+
sign with your wallet or HSM (see [`../examples/fireblocks`](../examples/fireblocks)) and `broadcast(...)`.
|
|
54
|
+
See the [root README](../README.md) for the full swap lifecycle.
|
|
55
|
+
|
|
56
|
+
MIT
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# hashlock-sdk (Python)
|
|
2
|
+
|
|
3
|
+
Python SDK for the [Hashlock Markets](https://github.com/Hashlock-Tech/hashlock-sdk) developer API —
|
|
4
|
+
non-custodial cross-chain atomic swaps (BTC ↔ EVM/TRON). Python 3.9+, built on `httpx`.
|
|
5
|
+
|
|
6
|
+
> ⚠️ **Sandbox / testnet preview (0.x)** — defaults to `api-dev.hashlock.markets`, testnets only,
|
|
7
|
+
> pre-mainnet. The API may change before the stable `1.0`. Not for mainnet funds.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install hashlock-sdk # add [ws] for the maker WebSocket feed: hashlock-sdk[ws]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from hashlock import HashlockClient, new_secret, verify_webhook
|
|
15
|
+
|
|
16
|
+
client = HashlockClient(api_key=os.environ["HASHLOCK_API_KEY"])
|
|
17
|
+
client.me()
|
|
18
|
+
|
|
19
|
+
# cursor pagination — page or auto-iterate
|
|
20
|
+
page = client.list_swaps(limit=20)
|
|
21
|
+
for swap in client.swaps():
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
# initiator: secret stays private; submit only the hashlock
|
|
25
|
+
secret, hashlock = new_secret()
|
|
26
|
+
client.accept_terms(thread_id, hashlock=hashlock)
|
|
27
|
+
|
|
28
|
+
# webhooks: verify a delivery against the raw body
|
|
29
|
+
ok = verify_webhook(secret, raw_body, request.headers.get("x-hashlock-signature"), request.headers.get("x-hashlock-timestamp"))
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Settlement is custody-agnostic: `build_fund` / `build_claim` / `build_refund` return unsigned transactions —
|
|
33
|
+
sign with your wallet or HSM (see [`../examples/fireblocks`](../examples/fireblocks)) and `broadcast(...)`.
|
|
34
|
+
See the [root README](../README.md) for the full swap lifecycle.
|
|
35
|
+
|
|
36
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Hashlock Markets developer SDK — non-custodial cross-chain atomic swaps (BTC ↔ EVM/TRON)."""
|
|
2
|
+
from .client import HashlockClient
|
|
3
|
+
from .errors import HashlockError
|
|
4
|
+
from .secret import new_secret, sha256_hex
|
|
5
|
+
from .webhooks import verify_webhook
|
|
6
|
+
|
|
7
|
+
__all__ = ["HashlockClient", "HashlockError", "new_secret", "sha256_hex", "verify_webhook"]
|
|
8
|
+
__version__ = "0.4.0"
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Thin, typed client for the Hashlock Markets developer API (/v1).
|
|
2
|
+
|
|
3
|
+
Custody-agnostic: the settlement endpoints return UNSIGNED transactions you sign with your own key/HSM
|
|
4
|
+
(see ``examples/``), then hand back to :meth:`HashlockClient.broadcast`. The server never holds your keys.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any, Iterator, Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .errors import HashlockError
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE = "https://api-dev.hashlock.markets/v1"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HashlockClient:
|
|
18
|
+
def __init__(self, api_key: str, base_url: str = DEFAULT_BASE, timeout: float = 30.0) -> None:
|
|
19
|
+
if not api_key:
|
|
20
|
+
raise ValueError("api_key is required")
|
|
21
|
+
self._base = base_url.rstrip("/")
|
|
22
|
+
self._http = httpx.Client(timeout=timeout, headers={"authorization": f"Bearer {api_key}"})
|
|
23
|
+
|
|
24
|
+
# ── context manager ────────────────────────────────────────────────────────
|
|
25
|
+
def __enter__(self) -> "HashlockClient":
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def __exit__(self, *exc: object) -> None:
|
|
29
|
+
self.close()
|
|
30
|
+
|
|
31
|
+
def close(self) -> None:
|
|
32
|
+
self._http.close()
|
|
33
|
+
|
|
34
|
+
# ── core ───────────────────────────────────────────────────────────────────
|
|
35
|
+
def me(self) -> dict[str, Any]:
|
|
36
|
+
"""Verify the key and see its scopes."""
|
|
37
|
+
return self._request("GET", "/me")
|
|
38
|
+
|
|
39
|
+
def assets(self) -> list[dict[str, Any]]:
|
|
40
|
+
"""The asset registry (chain, token|native, decimals, symbol)."""
|
|
41
|
+
return self._request("GET", "/assets")["assets"]
|
|
42
|
+
|
|
43
|
+
# ── RFQs ─────────────────────────────────────────────────────────────────────
|
|
44
|
+
def list_rfqs(self, **params: Any) -> dict[str, Any]:
|
|
45
|
+
"""One page of the order book: ``{"items": [...], "next_cursor": str | None}``.
|
|
46
|
+
|
|
47
|
+
Query params: base_asset_id, quote_asset_id, direction, limit, cursor.
|
|
48
|
+
"""
|
|
49
|
+
r = self._request("GET", "/rfqs", params=_camel(params))
|
|
50
|
+
return {"items": r.get("rfqs", []), "next_cursor": r.get("nextCursor")}
|
|
51
|
+
|
|
52
|
+
def rfqs(self, **params: Any) -> Iterator[dict[str, Any]]:
|
|
53
|
+
"""Iterate the whole order book, following the cursor automatically."""
|
|
54
|
+
yield from self._paginate(self.list_rfqs, params)
|
|
55
|
+
|
|
56
|
+
def get_rfq(self, rfq_id: str) -> dict[str, Any]:
|
|
57
|
+
return self._request("GET", f"/rfqs/{rfq_id}")["rfq"]
|
|
58
|
+
|
|
59
|
+
def create_rfq(
|
|
60
|
+
self,
|
|
61
|
+
direction: str,
|
|
62
|
+
base_asset_id: str,
|
|
63
|
+
base_amount: str,
|
|
64
|
+
quote_asset_id: str,
|
|
65
|
+
ttl_seconds: int,
|
|
66
|
+
ask_amount: Optional[str] = None,
|
|
67
|
+
visibility: Optional[str] = None,
|
|
68
|
+
target_address: Optional[str] = None,
|
|
69
|
+
idempotency_key: Optional[str] = None,
|
|
70
|
+
) -> dict[str, Any]:
|
|
71
|
+
"""Create an RFQ (requires the ``taker`` scope)."""
|
|
72
|
+
body = {
|
|
73
|
+
"direction": direction,
|
|
74
|
+
"baseAssetId": base_asset_id,
|
|
75
|
+
"baseAmount": base_amount,
|
|
76
|
+
"quoteAssetId": quote_asset_id,
|
|
77
|
+
"ttlSeconds": ttl_seconds,
|
|
78
|
+
"askAmount": ask_amount,
|
|
79
|
+
"visibility": visibility,
|
|
80
|
+
"targetAddress": target_address,
|
|
81
|
+
}
|
|
82
|
+
return self._request("POST", "/rfqs", json={k: v for k, v in body.items() if v is not None}, idempotency_key=idempotency_key)["rfq"]
|
|
83
|
+
|
|
84
|
+
def quote_rfq(self, rfq_id: str, quote_amount: str, idempotency_key: Optional[str] = None) -> dict[str, Any]:
|
|
85
|
+
"""Quote an RFQ (requires the ``maker`` scope) — opens a settlement thread."""
|
|
86
|
+
return self._request("POST", f"/rfqs/{rfq_id}/quotes", json={"quoteAmount": quote_amount}, idempotency_key=idempotency_key)
|
|
87
|
+
|
|
88
|
+
# ── negotiation thread ───────────────────────────────────────────────────────
|
|
89
|
+
def get_thread(self, thread_id: str) -> dict[str, Any]:
|
|
90
|
+
return self._request("GET", f"/threads/{thread_id}")
|
|
91
|
+
|
|
92
|
+
def propose_terms(self, thread_id: str, quote_amount: str) -> dict[str, Any]:
|
|
93
|
+
return self._request("POST", f"/threads/{thread_id}/propose", json={"quoteAmount": quote_amount})
|
|
94
|
+
|
|
95
|
+
def accept_proposal(self, thread_id: str) -> dict[str, Any]:
|
|
96
|
+
return self._request("POST", f"/threads/{thread_id}/accept-proposal")
|
|
97
|
+
|
|
98
|
+
def accept_terms(self, thread_id: str, hashlock: Optional[str] = None) -> dict[str, Any]:
|
|
99
|
+
"""Accept the current terms. When BOTH sides accept, the swap is created. The initiator (funds the
|
|
100
|
+
long leg) MUST pass ``hashlock`` = sha256(secret) — see :func:`hashlock.secret.new_secret`."""
|
|
101
|
+
return self._request("POST", f"/threads/{thread_id}/accept", json={"hashlock": hashlock} if hashlock else {})
|
|
102
|
+
|
|
103
|
+
# ── swaps ─────────────────────────────────────────────────────────────────────
|
|
104
|
+
def list_swaps(self, **params: Any) -> dict[str, Any]:
|
|
105
|
+
r = self._request("GET", "/swaps", params=_camel(params))
|
|
106
|
+
return {"items": r.get("swaps", []), "next_cursor": r.get("nextCursor")}
|
|
107
|
+
|
|
108
|
+
def swaps(self, **params: Any) -> Iterator[dict[str, Any]]:
|
|
109
|
+
yield from self._paginate(self.list_swaps, params)
|
|
110
|
+
|
|
111
|
+
def get_swap(self, swap_id: str) -> dict[str, Any]:
|
|
112
|
+
return self._request("GET", f"/swaps/{swap_id}")["swap"]
|
|
113
|
+
|
|
114
|
+
def set_swap_address(self, swap_id: str, chain: str, address: str) -> dict[str, Any]:
|
|
115
|
+
"""Set your receive (payout) / refund address for a leg. Bitcoin: the compressed pubkey (hex)."""
|
|
116
|
+
return self._request("POST", f"/swaps/{swap_id}/address", json={"chain": chain, "address": address})["swap"]
|
|
117
|
+
|
|
118
|
+
# ── settlement builders (UNSIGNED — sign with your own key/HSM, then broadcast) ─
|
|
119
|
+
def build_fund(self, swap_id: str, leg: str) -> dict[str, Any]:
|
|
120
|
+
return self._request("POST", f"/swaps/{swap_id}/legs/{leg}/fund")
|
|
121
|
+
|
|
122
|
+
def build_claim(self, swap_id: str, leg: str, secret: str) -> dict[str, Any]:
|
|
123
|
+
return self._request("POST", f"/swaps/{swap_id}/legs/{leg}/claim", json={"secret": secret})
|
|
124
|
+
|
|
125
|
+
def build_refund(self, swap_id: str, leg: str) -> dict[str, Any]:
|
|
126
|
+
return self._request("POST", f"/swaps/{swap_id}/legs/{leg}/refund")
|
|
127
|
+
|
|
128
|
+
def broadcast(self, chain: str, signed: Any, idempotency_key: Optional[str] = None) -> dict[str, Any]:
|
|
129
|
+
"""Relay a client-signed tx. ``chain``: 'evm' (0x raw) | 'tron' (signed obj) | 'bitcoin' (raw hex)."""
|
|
130
|
+
return self._request("POST", "/tx/broadcast", json={"chain": chain, "signed": signed}, idempotency_key=idempotency_key)
|
|
131
|
+
|
|
132
|
+
# ── webhooks ─────────────────────────────────────────────────────────────────
|
|
133
|
+
def list_webhooks(self) -> list[dict[str, Any]]:
|
|
134
|
+
return self._request("GET", "/webhooks")["webhooks"]
|
|
135
|
+
|
|
136
|
+
def create_webhook(self, url: str, events: Optional[list[str]] = None) -> dict[str, Any]:
|
|
137
|
+
"""Register a webhook. The returned ``secret`` is shown ONCE — store it to verify deliveries."""
|
|
138
|
+
body: dict[str, Any] = {"url": url}
|
|
139
|
+
if events:
|
|
140
|
+
body["events"] = events
|
|
141
|
+
return self._request("POST", "/webhooks", json=body)
|
|
142
|
+
|
|
143
|
+
def delete_webhook(self, webhook_id: str) -> dict[str, Any]:
|
|
144
|
+
return self._request("DELETE", f"/webhooks/{webhook_id}")
|
|
145
|
+
|
|
146
|
+
def ping_webhook(self, webhook_id: str) -> dict[str, Any]:
|
|
147
|
+
return self._request("POST", f"/webhooks/{webhook_id}/ping")
|
|
148
|
+
|
|
149
|
+
# ── internals ────────────────────────────────────────────────────────────────
|
|
150
|
+
def _paginate(self, page_fn: Any, params: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
|
151
|
+
cursor: Optional[str] = None
|
|
152
|
+
while True:
|
|
153
|
+
page = page_fn(**{**params, **({"cursor": cursor} if cursor else {})})
|
|
154
|
+
yield from page["items"]
|
|
155
|
+
cursor = page["next_cursor"]
|
|
156
|
+
if not cursor:
|
|
157
|
+
break
|
|
158
|
+
|
|
159
|
+
def _request(self, method: str, path: str, *, params: Any = None, json: Any = None, idempotency_key: Optional[str] = None) -> Any:
|
|
160
|
+
headers = {}
|
|
161
|
+
if idempotency_key:
|
|
162
|
+
headers["idempotency-key"] = idempotency_key
|
|
163
|
+
resp = self._http.request(method, self._base + path, params=params, json=json, headers=headers or None)
|
|
164
|
+
try:
|
|
165
|
+
data = resp.json()
|
|
166
|
+
except Exception:
|
|
167
|
+
data = None
|
|
168
|
+
if resp.status_code >= 400:
|
|
169
|
+
msg = (data or {}).get("error") if isinstance(data, dict) else None
|
|
170
|
+
retry_after = resp.headers.get("retry-after")
|
|
171
|
+
raise HashlockError(resp.status_code, msg or f"HTTP {resp.status_code}", data, int(retry_after) if retry_after else None)
|
|
172
|
+
return data
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _camel(params: dict[str, Any]) -> dict[str, Any]:
|
|
176
|
+
"""snake_case query params → the API's camelCase; drop Nones."""
|
|
177
|
+
out: dict[str, Any] = {}
|
|
178
|
+
for k, v in params.items():
|
|
179
|
+
if v is None:
|
|
180
|
+
continue
|
|
181
|
+
parts = k.split("_")
|
|
182
|
+
out[parts[0] + "".join(p.title() for p in parts[1:])] = v
|
|
183
|
+
return out
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Error type raised for non-2xx API responses."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class HashlockError(Exception):
|
|
8
|
+
def __init__(self, status: int, message: str, body: Any = None, retry_after: Optional[int] = None) -> None:
|
|
9
|
+
super().__init__(message)
|
|
10
|
+
self.status = status
|
|
11
|
+
self.body = body
|
|
12
|
+
self.retry_after = retry_after # seconds until the rate-limit window resets (on 429)
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def is_rate_limited(self) -> bool:
|
|
16
|
+
return self.status == 429
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def is_auth_error(self) -> bool:
|
|
20
|
+
return self.status in (401, 403)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""The swap secret + its hashlock.
|
|
2
|
+
|
|
3
|
+
The INITIATOR (funds the long leg) generates the secret locally, keeps it private, and passes only
|
|
4
|
+
``hashlock = sha256(secret)`` to ``accept_terms``. The secret is revealed on-chain when the initiator
|
|
5
|
+
claims the counter-leg; keep it until then.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import secrets
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def new_secret() -> tuple[str, str]:
|
|
14
|
+
"""Return ``(secret_hex, hashlock_hex)`` — a fresh 32-byte secret and its sha256 hashlock (no 0x)."""
|
|
15
|
+
secret = secrets.token_bytes(32)
|
|
16
|
+
return secret.hex(), hashlib.sha256(secret).hexdigest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def sha256_hex(hex_str: str) -> str:
|
|
20
|
+
"""sha256(preimage) as hex — verify a preimage against a swap's hashlock."""
|
|
21
|
+
return hashlib.sha256(bytes.fromhex(hex_str[2:] if hex_str.startswith("0x") else hex_str)).hexdigest()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Verify webhook deliveries.
|
|
2
|
+
|
|
3
|
+
The server signs each POST with::
|
|
4
|
+
|
|
5
|
+
X-Hashlock-Signature: sha256=HMAC-SHA256(secret, f"{timestamp}.{raw_body}")
|
|
6
|
+
|
|
7
|
+
where ``timestamp`` is the ``X-Hashlock-Timestamp`` header. Verify against the RAW request body.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import time
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def verify_webhook(
|
|
18
|
+
secret: str,
|
|
19
|
+
raw_body: str,
|
|
20
|
+
signature: Optional[str],
|
|
21
|
+
timestamp: Optional[str],
|
|
22
|
+
tolerance_seconds: int = 300,
|
|
23
|
+
now_unix: Optional[int] = None,
|
|
24
|
+
) -> bool:
|
|
25
|
+
"""Return True iff the signature matches and the timestamp is within tolerance (0 disables the check)."""
|
|
26
|
+
if not signature or not timestamp:
|
|
27
|
+
return False
|
|
28
|
+
if tolerance_seconds > 0:
|
|
29
|
+
try:
|
|
30
|
+
ts = int(timestamp)
|
|
31
|
+
except ValueError:
|
|
32
|
+
return False
|
|
33
|
+
if abs((now_unix if now_unix is not None else int(time.time())) - ts) > tolerance_seconds:
|
|
34
|
+
return False
|
|
35
|
+
expected = "sha256=" + hmac.new(secret.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256).hexdigest()
|
|
36
|
+
return hmac.compare_digest(expected, signature)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Maker feed over WebSocket (/v1/ws), using the ``websockets`` library (async).
|
|
2
|
+
|
|
3
|
+
Authenticate with an API key that has the ``maker`` scope, receive a snapshot of the public order book,
|
|
4
|
+
then a live stream of quotable RFQs; submit quotes on the same socket.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import Any, AsyncIterator
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import websockets # type: ignore
|
|
13
|
+
except ImportError: # pragma: no cover
|
|
14
|
+
websockets = None # the maker feed is optional; install `hashlock-sdk[ws]`
|
|
15
|
+
|
|
16
|
+
DEFAULT_WS = "wss://api-dev.hashlock.markets/v1/ws"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def maker_feed(api_key: str, url: str = DEFAULT_WS) -> AsyncIterator[dict[str, Any]]:
|
|
20
|
+
"""Async-iterate feed messages: {'type': 'snapshot'|'ready'|'rfq'|'quoted'|'error', ...}.
|
|
21
|
+
|
|
22
|
+
Example::
|
|
23
|
+
|
|
24
|
+
async for msg in maker_feed(api_key):
|
|
25
|
+
if msg["type"] == "rfq":
|
|
26
|
+
... # decide whether to quote
|
|
27
|
+
"""
|
|
28
|
+
if websockets is None:
|
|
29
|
+
raise RuntimeError("install `hashlock-sdk[ws]` (the `websockets` package) to use the maker feed")
|
|
30
|
+
async with websockets.connect(url) as ws:
|
|
31
|
+
await ws.send(json.dumps({"apiKey": api_key}))
|
|
32
|
+
async for raw in ws:
|
|
33
|
+
try:
|
|
34
|
+
yield json.loads(raw)
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def submit_quote(ws: Any, rfq_id: str, quote_amount: str) -> None:
|
|
40
|
+
"""Submit a quote on an open maker-feed socket."""
|
|
41
|
+
await ws.send(json.dumps({"quote": {"rfqId": rfq_id, "quoteAmount": quote_amount}}))
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hashlock-sdk"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Python SDK for the Hashlock Markets developer API — non-custodial cross-chain atomic swaps (BTC <-> EVM/TRON) over sealed RFQ + HTLC."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
keywords = ["hashlock", "atomic-swap", "htlc", "bitcoin", "cross-chain", "otc", "defi"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Intended Audience :: Financial and Insurance Industry",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
20
|
+
"Topic :: Office/Business :: Financial",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["httpx>=0.24"]
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
ws = ["websockets>=12"]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/Hashlock-Tech/hashlock-sdk"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["hashlock"]
|