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.
@@ -0,0 +1,9 @@
1
+ node_modules/
2
+ dist/
3
+ *.tsbuildinfo
4
+ __pycache__/
5
+ *.egg-info/
6
+ build/
7
+ .venv/
8
+ .env
9
+ .DS_Store
@@ -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"]