layerhop 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ dist/
6
+ build/
layerhop-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LayerHop
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.5
2
+ Name: layerhop
3
+ Version: 0.1.0
4
+ Summary: LayerHop SDK - best-price cross-chain and same-chain swaps (sync + async, httpx)
5
+ Project-URL: Homepage, https://layerhop.com
6
+ Project-URL: Changelog, https://docs.layerhop.com/changelog
7
+ Project-URL: Documentation, https://docs.layerhop.com/sdks/python
8
+ Project-URL: Repository, https://github.com/layerhop/layerhop-python
9
+ Project-URL: Issues, https://github.com/layerhop/layerhop-python/issues
10
+ Author-email: LayerHop <dev@layerhop.com>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: bridge,bridge-aggregator,cross-chain,defi,dex-aggregator,ethereum,evm,layerhop,sdk,solana,swap,web3
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Framework :: AsyncIO
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.9
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Programming Language :: Python :: 3.14
26
+ Classifier: Topic :: Office/Business :: Financial
27
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
28
+ Classifier: Typing :: Typed
29
+ Requires-Python: >=3.9
30
+ Requires-Dist: httpx<1,>=0.24
31
+ Requires-Dist: typing-extensions>=4.5; python_version < '3.11'
32
+ Provides-Extra: dev
33
+ Requires-Dist: anyio>=4; extra == 'dev'
34
+ Requires-Dist: pytest>=7; extra == 'dev'
35
+ Description-Content-Type: text/markdown
36
+
37
+ # layerhop (Python SDK)
38
+
39
+ [![PyPI](https://img.shields.io/pypi/v/layerhop?color=c3f06b&labelColor=090d15)](https://pypi.org/project/layerhop/) [![Python](https://img.shields.io/pypi/pyversions/layerhop?color=7ddec8&labelColor=090d15)](https://pypi.org/project/layerhop/) [![license](https://img.shields.io/pypi/l/layerhop?color=7ddec8&labelColor=090d15)](https://opensource.org/license/mit) [![docs](https://img.shields.io/badge/docs-docs.layerhop.com-7ddec8?labelColor=090d15)](https://docs.layerhop.com/sdks/python)
40
+
41
+ Best-price cross-chain and same-chain swaps through one non-custodial API. Sync and async clients
42
+ on top of [httpx](https://www.python-httpx.org/), mirroring the TypeScript `@layerhop/sdk`.
43
+
44
+ > **The SDK never holds keys.** It returns *unsigned* transactions. Your own wallet library
45
+ > (web3.py, eth-account, solders, a KMS, a hardware wallet…) signs and broadcasts them.
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install layerhop
51
+ ```
52
+
53
+ Python 3.9+. The only runtime dependency is `httpx`.
54
+
55
+ ## Quickstart
56
+
57
+ Amounts are **integer strings in base units** (100 USDC = `"100000000"`), never floats.
58
+
59
+ ```python
60
+ from layerhop import LayerHop
61
+
62
+ lh = LayerHop(api_key="lh_live_...") # api_key is optional
63
+
64
+ # 1. Quote: every provider is asked; `best` pays the user the most.
65
+ q = lh.quote("base", "USDC", "100000000", "solana", "SOL",
66
+ sender="0xYourEvmAddress", recipient="YourSolanaAddress")
67
+ best = q["best"]
68
+ print(best["provider"], best["dstAmount"], best["estimatedSeconds"])
69
+
70
+ # 2. Build the unsigned transaction.
71
+ tx = lh.build_tx(best["quoteId"])
72
+
73
+ # 3. Sign and send with YOUR wallet library (example: web3.py on Base).
74
+ if tx["vm"] == "evm":
75
+ # if tx.get("approval"): send ERC-20 approve(spender, amount) on approval["token"] first
76
+ tx_hash = w3.eth.send_transaction({
77
+ "to": tx["to"], "data": tx["data"], "value": int(tx["value"]), "chainId": tx["chainId"],
78
+ }).hex()
79
+ else:
80
+ # Solana: tx["serializedTx"] is a base64 VersionedTransaction; sign it with solders.
81
+ # When tx.get("submitViaApi") is true, only sign, then: tx_hash = lh.submit(best["quoteId"], signed_b64)
82
+ ...
83
+
84
+ # 4. Tell LayerHop you broadcast it (enables tracking, history and webhooks).
85
+ lh.report_submitted(best["quoteId"], tx_hash)
86
+
87
+ # 5. Track until filled / refunded / failed.
88
+ for s in lh.watch(best["provider"], tx_hash):
89
+ print(s["state"], s.get("dstTxHash"))
90
+ ```
91
+
92
+ ### Async
93
+
94
+ ```python
95
+ from layerhop import AsyncLayerHop
96
+
97
+ async with AsyncLayerHop() as lh:
98
+ q = await lh.quote("base", "USDC", "100000000", "solana", "SOL", sender=s, recipient=r)
99
+ async for st in lh.watch("relay", tx_hash):
100
+ print(st["state"])
101
+ ```
102
+
103
+ ## API
104
+
105
+ | Method | Endpoint |
106
+ |---|---|
107
+ | `health()` | `GET /v1/health` |
108
+ | `chains()` | `GET /v1/chains` |
109
+ | `tokens(chain)` | `GET /v1/tokens?chain=` |
110
+ | `quote(src_chain, src_token, amount, dst_chain, dst_token, *, sender, recipient, slippage_bps=50)` | `GET /v1/quote` |
111
+ | `build_tx(quote_id)` | `POST /v1/orders` → the `tx` |
112
+ | `simulate(quote_id)` | `POST /v1/simulate` (dry-run, records nothing) |
113
+ | `submit(quote_id, signed_transaction)` | `POST /v1/orders/submit` (Solana `submitViaApi` only) |
114
+ | `report_submitted(quote_id, tx_hash)` | `POST /v1/orders/{quoteId}/submitted` |
115
+ | `status(provider, id)` | `GET /v1/orders/{provider}/{id}` |
116
+ | `watch(provider, id, *, interval=3.0, timeout=1800)` | polls `status`, yields on change, stops when final |
117
+ | `list_orders(*, address, status, cursor, limit)` | `GET /v1/orders` |
118
+
119
+ Client options: `base_url` (default `https://api.layerhop.com`), `api_key` (sent as `x-api-key`),
120
+ `channel` (default `"api"`), `integrator`, `integrator_fee_bps`, `timeout`, and `http_client` /
121
+ `transport` for custom httpx setups or tests.
122
+
123
+ Responses are plain dicts typed with `TypedDict` (keys keep the API's camelCase), so new fields
124
+ never break you.
125
+
126
+ ## Errors
127
+
128
+ Every non-2xx response raises `LayerHopApiError` with a stable `code` (`QUOTE_EXPIRED`,
129
+ `NO_ROUTE`, …), `status`, `message` and `request_id`. See
130
+ `https://docs.layerhop.com/errors/<code>`.
131
+
132
+ ```python
133
+ from layerhop import LayerHopApiError
134
+ try:
135
+ lh.build_tx(quote_id)
136
+ except LayerHopApiError as e:
137
+ if e.code == "QUOTE_EXPIRED":
138
+ ... # re-quote
139
+ ```
140
+
141
+ ## Webhooks
142
+
143
+ ```python
144
+ from layerhop import verify_webhook_signature, construct_webhook_event
145
+
146
+ # Flask example — use the RAW body
147
+ ok = verify_webhook_signature(secret, request.get_data(), request.headers.get("LayerHop-Signature"))
148
+ event = construct_webhook_event(secret, request.get_data(), request.headers.get("LayerHop-Signature"))
149
+ ```
150
+
151
+ The header is `t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>`. Signatures older or newer
152
+ than 300 s (`tolerance`) are rejected; comparison is constant-time.
153
+
154
+ ## Development
155
+
156
+ ```bash
157
+ python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
158
+ .venv/bin/python -m pytest
159
+ ```
160
+
161
+ MIT licensed.
@@ -0,0 +1,125 @@
1
+ # layerhop (Python SDK)
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/layerhop?color=c3f06b&labelColor=090d15)](https://pypi.org/project/layerhop/) [![Python](https://img.shields.io/pypi/pyversions/layerhop?color=7ddec8&labelColor=090d15)](https://pypi.org/project/layerhop/) [![license](https://img.shields.io/pypi/l/layerhop?color=7ddec8&labelColor=090d15)](https://opensource.org/license/mit) [![docs](https://img.shields.io/badge/docs-docs.layerhop.com-7ddec8?labelColor=090d15)](https://docs.layerhop.com/sdks/python)
4
+
5
+ Best-price cross-chain and same-chain swaps through one non-custodial API. Sync and async clients
6
+ on top of [httpx](https://www.python-httpx.org/), mirroring the TypeScript `@layerhop/sdk`.
7
+
8
+ > **The SDK never holds keys.** It returns *unsigned* transactions. Your own wallet library
9
+ > (web3.py, eth-account, solders, a KMS, a hardware wallet…) signs and broadcasts them.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install layerhop
15
+ ```
16
+
17
+ Python 3.9+. The only runtime dependency is `httpx`.
18
+
19
+ ## Quickstart
20
+
21
+ Amounts are **integer strings in base units** (100 USDC = `"100000000"`), never floats.
22
+
23
+ ```python
24
+ from layerhop import LayerHop
25
+
26
+ lh = LayerHop(api_key="lh_live_...") # api_key is optional
27
+
28
+ # 1. Quote: every provider is asked; `best` pays the user the most.
29
+ q = lh.quote("base", "USDC", "100000000", "solana", "SOL",
30
+ sender="0xYourEvmAddress", recipient="YourSolanaAddress")
31
+ best = q["best"]
32
+ print(best["provider"], best["dstAmount"], best["estimatedSeconds"])
33
+
34
+ # 2. Build the unsigned transaction.
35
+ tx = lh.build_tx(best["quoteId"])
36
+
37
+ # 3. Sign and send with YOUR wallet library (example: web3.py on Base).
38
+ if tx["vm"] == "evm":
39
+ # if tx.get("approval"): send ERC-20 approve(spender, amount) on approval["token"] first
40
+ tx_hash = w3.eth.send_transaction({
41
+ "to": tx["to"], "data": tx["data"], "value": int(tx["value"]), "chainId": tx["chainId"],
42
+ }).hex()
43
+ else:
44
+ # Solana: tx["serializedTx"] is a base64 VersionedTransaction; sign it with solders.
45
+ # When tx.get("submitViaApi") is true, only sign, then: tx_hash = lh.submit(best["quoteId"], signed_b64)
46
+ ...
47
+
48
+ # 4. Tell LayerHop you broadcast it (enables tracking, history and webhooks).
49
+ lh.report_submitted(best["quoteId"], tx_hash)
50
+
51
+ # 5. Track until filled / refunded / failed.
52
+ for s in lh.watch(best["provider"], tx_hash):
53
+ print(s["state"], s.get("dstTxHash"))
54
+ ```
55
+
56
+ ### Async
57
+
58
+ ```python
59
+ from layerhop import AsyncLayerHop
60
+
61
+ async with AsyncLayerHop() as lh:
62
+ q = await lh.quote("base", "USDC", "100000000", "solana", "SOL", sender=s, recipient=r)
63
+ async for st in lh.watch("relay", tx_hash):
64
+ print(st["state"])
65
+ ```
66
+
67
+ ## API
68
+
69
+ | Method | Endpoint |
70
+ |---|---|
71
+ | `health()` | `GET /v1/health` |
72
+ | `chains()` | `GET /v1/chains` |
73
+ | `tokens(chain)` | `GET /v1/tokens?chain=` |
74
+ | `quote(src_chain, src_token, amount, dst_chain, dst_token, *, sender, recipient, slippage_bps=50)` | `GET /v1/quote` |
75
+ | `build_tx(quote_id)` | `POST /v1/orders` → the `tx` |
76
+ | `simulate(quote_id)` | `POST /v1/simulate` (dry-run, records nothing) |
77
+ | `submit(quote_id, signed_transaction)` | `POST /v1/orders/submit` (Solana `submitViaApi` only) |
78
+ | `report_submitted(quote_id, tx_hash)` | `POST /v1/orders/{quoteId}/submitted` |
79
+ | `status(provider, id)` | `GET /v1/orders/{provider}/{id}` |
80
+ | `watch(provider, id, *, interval=3.0, timeout=1800)` | polls `status`, yields on change, stops when final |
81
+ | `list_orders(*, address, status, cursor, limit)` | `GET /v1/orders` |
82
+
83
+ Client options: `base_url` (default `https://api.layerhop.com`), `api_key` (sent as `x-api-key`),
84
+ `channel` (default `"api"`), `integrator`, `integrator_fee_bps`, `timeout`, and `http_client` /
85
+ `transport` for custom httpx setups or tests.
86
+
87
+ Responses are plain dicts typed with `TypedDict` (keys keep the API's camelCase), so new fields
88
+ never break you.
89
+
90
+ ## Errors
91
+
92
+ Every non-2xx response raises `LayerHopApiError` with a stable `code` (`QUOTE_EXPIRED`,
93
+ `NO_ROUTE`, …), `status`, `message` and `request_id`. See
94
+ `https://docs.layerhop.com/errors/<code>`.
95
+
96
+ ```python
97
+ from layerhop import LayerHopApiError
98
+ try:
99
+ lh.build_tx(quote_id)
100
+ except LayerHopApiError as e:
101
+ if e.code == "QUOTE_EXPIRED":
102
+ ... # re-quote
103
+ ```
104
+
105
+ ## Webhooks
106
+
107
+ ```python
108
+ from layerhop import verify_webhook_signature, construct_webhook_event
109
+
110
+ # Flask example — use the RAW body
111
+ ok = verify_webhook_signature(secret, request.get_data(), request.headers.get("LayerHop-Signature"))
112
+ event = construct_webhook_event(secret, request.get_data(), request.headers.get("LayerHop-Signature"))
113
+ ```
114
+
115
+ The header is `t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<body>")>`. Signatures older or newer
116
+ than 300 s (`tolerance`) are rejected; comparison is constant-time.
117
+
118
+ ## Development
119
+
120
+ ```bash
121
+ python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'
122
+ .venv/bin/python -m pytest
123
+ ```
124
+
125
+ MIT licensed.
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "layerhop"
7
+ version = "0.1.0"
8
+ description = "LayerHop SDK - best-price cross-chain and same-chain swaps (sync + async, httpx)"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "LayerHop", email = "dev@layerhop.com" }]
14
+ dependencies = ["httpx>=0.24,<1", "typing_extensions>=4.5; python_version < \"3.11\""]
15
+ keywords = ["layerhop", "bridge", "swap", "cross-chain", "solana", "evm", "ethereum", "defi", "web3", "dex-aggregator", "bridge-aggregator", "sdk"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
28
+ "Topic :: Office/Business :: Financial",
29
+ "Topic :: Software Development :: Libraries :: Python Modules",
30
+ "Typing :: Typed",
31
+ "Framework :: AsyncIO",
32
+ ]
33
+
34
+ [project.optional-dependencies]
35
+ dev = ["pytest>=7", "anyio>=4"]
36
+
37
+ [project.urls]
38
+ Homepage = "https://layerhop.com"
39
+ Changelog = "https://docs.layerhop.com/changelog"
40
+ Documentation = "https://docs.layerhop.com/sdks/python"
41
+ Repository = "https://github.com/layerhop/layerhop-python"
42
+ Issues = "https://github.com/layerhop/layerhop-python/issues"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/layerhop"]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
@@ -0,0 +1,48 @@
1
+ """LayerHop Python SDK -- best-price cross-chain and same-chain swaps.
2
+
3
+ The SDK never holds private keys: it returns unsigned transactions for your own wallet to sign.
4
+ """
5
+
6
+ from .client import DEFAULT_BASE_URL, AsyncLayerHop, LayerHop, __version__
7
+ from .errors import LayerHopApiError, WebhookSignatureError
8
+ from .types import (
9
+ FINAL_STATES,
10
+ Approval,
11
+ BuiltTx,
12
+ Chain,
13
+ EvmTx,
14
+ FeeBreakdown,
15
+ FeeChannel,
16
+ Order,
17
+ OrdersPage,
18
+ OrderState,
19
+ OrderStatus,
20
+ ProviderError,
21
+ ProviderId,
22
+ Quote,
23
+ QuoteResponse,
24
+ ReportSubmittedResponse,
25
+ SimulateError,
26
+ SimulateResponse,
27
+ SvmTx,
28
+ Token,
29
+ )
30
+ from .webhooks import (
31
+ SIGNATURE_HEADER,
32
+ compute_webhook_signature,
33
+ construct_webhook_event,
34
+ verify_webhook_signature,
35
+ )
36
+
37
+ SOLANA_CHAIN_ID = 7565164
38
+ EVM_NATIVE = "0x0000000000000000000000000000000000000000"
39
+ SOL_NATIVE = "So11111111111111111111111111111111111111112"
40
+
41
+ __all__ = [
42
+ "LayerHop", "AsyncLayerHop", "LayerHopApiError", "WebhookSignatureError", "DEFAULT_BASE_URL", "__version__",
43
+ "verify_webhook_signature", "construct_webhook_event", "compute_webhook_signature", "SIGNATURE_HEADER",
44
+ "SOLANA_CHAIN_ID", "EVM_NATIVE", "SOL_NATIVE", "FINAL_STATES",
45
+ "Approval", "BuiltTx", "Chain", "EvmTx", "FeeBreakdown", "FeeChannel", "Order", "OrdersPage", "OrderState",
46
+ "OrderStatus", "ProviderError", "ProviderId", "Quote", "QuoteResponse", "ReportSubmittedResponse",
47
+ "SimulateError", "SimulateResponse", "SvmTx", "Token",
48
+ ]
@@ -0,0 +1,292 @@
1
+ """Sync and async LayerHop clients (httpx).
2
+
3
+ The SDK never holds private keys: it returns unsigned transactions that your own wallet library
4
+ signs and broadcasts.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import time
11
+ from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Union
12
+ from urllib.parse import quote as _urlquote
13
+
14
+ import httpx
15
+
16
+ from .errors import LayerHopApiError
17
+ from .types import (
18
+ FINAL_STATES,
19
+ BuiltTx,
20
+ Chain,
21
+ FeeChannel,
22
+ OrdersPage,
23
+ OrderStatus,
24
+ QuoteResponse,
25
+ ReportSubmittedResponse,
26
+ SimulateResponse,
27
+ Token,
28
+ )
29
+
30
+ DEFAULT_BASE_URL = "https://api.layerhop.com"
31
+ DEFAULT_TIMEOUT = 30.0
32
+ __version__ = "0.1.0"
33
+
34
+
35
+ def _seg(v: str) -> str:
36
+ return _urlquote(str(v), safe="")
37
+
38
+
39
+ class _Base:
40
+ def __init__(
41
+ self,
42
+ *,
43
+ base_url: Optional[str] = None,
44
+ api_key: Optional[str] = None,
45
+ channel: FeeChannel = "api",
46
+ integrator: Optional[str] = None,
47
+ integrator_fee_bps: Optional[int] = None,
48
+ timeout: float = DEFAULT_TIMEOUT,
49
+ ) -> None:
50
+ self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
51
+ self.api_key = api_key
52
+ self.channel = channel
53
+ self.integrator = integrator
54
+ self.integrator_fee_bps = integrator_fee_bps
55
+ self.timeout = timeout
56
+
57
+ def _headers(self) -> Dict[str, str]:
58
+ h = {"accept": "application/json", "user-agent": f"layerhop-python/{__version__}"}
59
+ if self.api_key:
60
+ h["x-api-key"] = self.api_key
61
+ return h
62
+
63
+ def _url(self, path: str) -> str:
64
+ return f"{self.base_url}/v1{path}"
65
+
66
+ def _quote_params(
67
+ self, src_chain: Union[str, int], src_token: str, amount: str, dst_chain: Union[str, int],
68
+ dst_token: str, sender: str, recipient: str, slippage_bps: int,
69
+ ) -> Dict[str, str]:
70
+ if not isinstance(amount, str):
71
+ raise TypeError("amount must be an integer string in base units (e.g. '100000000')")
72
+ q = {
73
+ "srcChain": str(src_chain), "srcToken": src_token, "amount": amount,
74
+ "dstChain": str(dst_chain), "dstToken": dst_token, "sender": sender, "recipient": recipient,
75
+ "slippageBps": str(slippage_bps), "channel": self.channel,
76
+ }
77
+ if self.integrator:
78
+ q["integrator"] = self.integrator
79
+ if self.integrator_fee_bps is not None:
80
+ q["integratorFeeBps"] = str(self.integrator_fee_bps)
81
+ return q
82
+
83
+ @staticmethod
84
+ def _orders_params(address: Optional[str], status: Optional[str], cursor: Optional[str], limit: Optional[int]) -> Dict[str, str]:
85
+ p: Dict[str, str] = {}
86
+ if address:
87
+ p["address"] = address
88
+ if status:
89
+ p["status"] = status
90
+ if cursor:
91
+ p["cursor"] = cursor
92
+ if limit is not None:
93
+ p["limit"] = str(limit)
94
+ return p
95
+
96
+ @staticmethod
97
+ def _handle(res: httpx.Response) -> Any:
98
+ try:
99
+ body = res.json()
100
+ except ValueError:
101
+ body = {}
102
+ if res.is_success:
103
+ return body
104
+ err = body.get("error") if isinstance(body, dict) else None
105
+ if not isinstance(err, dict):
106
+ err = {}
107
+ raise LayerHopApiError(
108
+ code=str(err.get("code") or "INTERNAL"),
109
+ message=str(err.get("message") or res.reason_phrase or f"HTTP {res.status_code}"),
110
+ status=res.status_code,
111
+ request_id=err.get("requestId") or res.headers.get("x-request-id"),
112
+ docs_url=err.get("docsUrl"),
113
+ )
114
+
115
+
116
+ class LayerHop(_Base):
117
+ """Synchronous client.
118
+
119
+ >>> lh = LayerHop(api_key="lh_live_...")
120
+ >>> q = lh.quote("base", "USDC", "100000000", "solana", "SOL", sender="0x...", recipient="9WzD...")
121
+ """
122
+
123
+ def __init__(
124
+ self,
125
+ *,
126
+ base_url: Optional[str] = None,
127
+ api_key: Optional[str] = None,
128
+ channel: FeeChannel = "api",
129
+ integrator: Optional[str] = None,
130
+ integrator_fee_bps: Optional[int] = None,
131
+ timeout: float = DEFAULT_TIMEOUT,
132
+ http_client: Optional[httpx.Client] = None,
133
+ transport: Optional[httpx.BaseTransport] = None,
134
+ ) -> None:
135
+ super().__init__(base_url=base_url, api_key=api_key, channel=channel, integrator=integrator,
136
+ integrator_fee_bps=integrator_fee_bps, timeout=timeout)
137
+ self._owns_client = http_client is None
138
+ self._client = http_client or httpx.Client(timeout=timeout, transport=transport)
139
+
140
+ def close(self) -> None:
141
+ if self._owns_client:
142
+ self._client.close()
143
+
144
+ def __enter__(self) -> "LayerHop":
145
+ return self
146
+
147
+ def __exit__(self, *exc: Any) -> None:
148
+ self.close()
149
+
150
+ def _call(self, method: str, path: str, *, params: Optional[Dict[str, str]] = None, json: Any = None) -> Any:
151
+ res = self._client.request(method, self._url(path), params=params, json=json, headers=self._headers())
152
+ return self._handle(res)
153
+
154
+ def health(self) -> Dict[str, Any]:
155
+ return self._call("GET", "/health")
156
+
157
+ def chains(self) -> List[Chain]:
158
+ return self._call("GET", "/chains")["chains"]
159
+
160
+ def tokens(self, chain: Union[str, int]) -> List[Token]:
161
+ return self._call("GET", "/tokens", params={"chain": str(chain)})["tokens"]
162
+
163
+ def quote(
164
+ self, src_chain: Union[str, int], src_token: str, amount: str, dst_chain: Union[str, int], dst_token: str,
165
+ *, sender: str, recipient: str, slippage_bps: int = 50,
166
+ ) -> QuoteResponse:
167
+ """Ask every provider; ``best`` is the highest output for the user. ``amount`` is base units."""
168
+ return self._call("GET", "/quote", params=self._quote_params(
169
+ src_chain, src_token, amount, dst_chain, dst_token, sender, recipient, slippage_bps))
170
+
171
+ def build_tx(self, quote_id: str) -> BuiltTx:
172
+ """The unsigned transaction for a quote. EVM txs may carry an ``approval`` to send first."""
173
+ return self._call("POST", "/orders", json={"quoteId": quote_id})["tx"]
174
+
175
+ def submit(self, quote_id: str, signed_transaction: str) -> str:
176
+ """For SvmTx with ``submitViaApi``: send the wallet-signed tx (base64). Returns the signature."""
177
+ return self._call("POST", "/orders/submit", json={"quoteId": quote_id, "signedTransaction": signed_transaction})["txHash"]
178
+
179
+ def report_submitted(self, quote_id: str, tx_hash: str) -> ReportSubmittedResponse:
180
+ """Tell LayerHop you broadcast the tx, so it tracks the order (and fires webhooks). Idempotent."""
181
+ return self._call("POST", f"/orders/{_seg(quote_id)}/submitted", json={"txHash": tx_hash})
182
+
183
+ def simulate(self, quote_id: str) -> SimulateResponse:
184
+ """Build and dry-run the tx without recording an order."""
185
+ return self._call("POST", "/simulate", json={"quoteId": quote_id})
186
+
187
+ def status(self, provider: str, id: str) -> OrderStatus:
188
+ return self._call("GET", f"/orders/{_seg(provider)}/{_seg(id)}")
189
+
190
+ def list_orders(
191
+ self, *, address: Optional[str] = None, status: Optional[str] = None,
192
+ cursor: Optional[str] = None, limit: Optional[int] = None,
193
+ ) -> OrdersPage:
194
+ """Orders by sender or recipient address, newest first. Pass ``nextCursor`` to page."""
195
+ return self._call("GET", "/orders", params=self._orders_params(address, status, cursor, limit))
196
+
197
+ def watch(self, provider: str, id: str, *, interval: float = 3.0, timeout: float = 1800.0) -> Iterator[OrderStatus]:
198
+ """Poll until the order is final (filled/refunded/failed), yielding each state change."""
199
+ end = time.monotonic() + timeout
200
+ last = None
201
+ while time.monotonic() < end:
202
+ s = self.status(provider, id)
203
+ if s["state"] != last:
204
+ last = s["state"]
205
+ yield s
206
+ if s["state"] in FINAL_STATES:
207
+ return
208
+ time.sleep(interval)
209
+
210
+
211
+ class AsyncLayerHop(_Base):
212
+ """Asynchronous client with the same methods as :class:`LayerHop` (``await``-able)."""
213
+
214
+ def __init__(
215
+ self,
216
+ *,
217
+ base_url: Optional[str] = None,
218
+ api_key: Optional[str] = None,
219
+ channel: FeeChannel = "api",
220
+ integrator: Optional[str] = None,
221
+ integrator_fee_bps: Optional[int] = None,
222
+ timeout: float = DEFAULT_TIMEOUT,
223
+ http_client: Optional[httpx.AsyncClient] = None,
224
+ transport: Optional[httpx.AsyncBaseTransport] = None,
225
+ ) -> None:
226
+ super().__init__(base_url=base_url, api_key=api_key, channel=channel, integrator=integrator,
227
+ integrator_fee_bps=integrator_fee_bps, timeout=timeout)
228
+ self._owns_client = http_client is None
229
+ self._client = http_client or httpx.AsyncClient(timeout=timeout, transport=transport)
230
+
231
+ async def aclose(self) -> None:
232
+ if self._owns_client:
233
+ await self._client.aclose()
234
+
235
+ async def __aenter__(self) -> "AsyncLayerHop":
236
+ return self
237
+
238
+ async def __aexit__(self, *exc: Any) -> None:
239
+ await self.aclose()
240
+
241
+ async def _call(self, method: str, path: str, *, params: Optional[Dict[str, str]] = None, json: Any = None) -> Any:
242
+ res = await self._client.request(method, self._url(path), params=params, json=json, headers=self._headers())
243
+ return self._handle(res)
244
+
245
+ async def health(self) -> Dict[str, Any]:
246
+ return await self._call("GET", "/health")
247
+
248
+ async def chains(self) -> List[Chain]:
249
+ return (await self._call("GET", "/chains"))["chains"]
250
+
251
+ async def tokens(self, chain: Union[str, int]) -> List[Token]:
252
+ return (await self._call("GET", "/tokens", params={"chain": str(chain)}))["tokens"]
253
+
254
+ async def quote(
255
+ self, src_chain: Union[str, int], src_token: str, amount: str, dst_chain: Union[str, int], dst_token: str,
256
+ *, sender: str, recipient: str, slippage_bps: int = 50,
257
+ ) -> QuoteResponse:
258
+ return await self._call("GET", "/quote", params=self._quote_params(
259
+ src_chain, src_token, amount, dst_chain, dst_token, sender, recipient, slippage_bps))
260
+
261
+ async def build_tx(self, quote_id: str) -> BuiltTx:
262
+ return (await self._call("POST", "/orders", json={"quoteId": quote_id}))["tx"]
263
+
264
+ async def submit(self, quote_id: str, signed_transaction: str) -> str:
265
+ return (await self._call("POST", "/orders/submit", json={"quoteId": quote_id, "signedTransaction": signed_transaction}))["txHash"]
266
+
267
+ async def report_submitted(self, quote_id: str, tx_hash: str) -> ReportSubmittedResponse:
268
+ return await self._call("POST", f"/orders/{_seg(quote_id)}/submitted", json={"txHash": tx_hash})
269
+
270
+ async def simulate(self, quote_id: str) -> SimulateResponse:
271
+ return await self._call("POST", "/simulate", json={"quoteId": quote_id})
272
+
273
+ async def status(self, provider: str, id: str) -> OrderStatus:
274
+ return await self._call("GET", f"/orders/{_seg(provider)}/{_seg(id)}")
275
+
276
+ async def list_orders(
277
+ self, *, address: Optional[str] = None, status: Optional[str] = None,
278
+ cursor: Optional[str] = None, limit: Optional[int] = None,
279
+ ) -> OrdersPage:
280
+ return await self._call("GET", "/orders", params=self._orders_params(address, status, cursor, limit))
281
+
282
+ async def watch(self, provider: str, id: str, *, interval: float = 3.0, timeout: float = 1800.0) -> AsyncIterator[OrderStatus]:
283
+ end = time.monotonic() + timeout
284
+ last = None
285
+ while time.monotonic() < end:
286
+ s = await self.status(provider, id)
287
+ if s["state"] != last:
288
+ last = s["state"]
289
+ yield s
290
+ if s["state"] in FINAL_STATES:
291
+ return
292
+ await asyncio.sleep(interval)
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional
4
+
5
+
6
+ class LayerHopApiError(Exception):
7
+ """A non-2xx API response. ``code`` is a stable error code, e.g. ``QUOTE_EXPIRED``.
8
+
9
+ See https://docs.layerhop.com/errors/<code> for causes and fixes.
10
+ """
11
+
12
+ def __init__(self, code: str, message: str, status: int, request_id: Optional[str] = None, docs_url: Optional[str] = None) -> None:
13
+ super().__init__(message)
14
+ self.code = code
15
+ self.message = message
16
+ self.status = status
17
+ self.request_id = request_id
18
+ self.docs_url = docs_url
19
+
20
+ def __repr__(self) -> str:
21
+ return f"LayerHopApiError(code={self.code!r}, status={self.status}, message={self.message!r}, request_id={self.request_id!r})"
22
+
23
+ def __str__(self) -> str:
24
+ return f"{self.code}: {self.message}"
25
+
26
+
27
+ class WebhookSignatureError(Exception):
28
+ """Raised by :func:`layerhop.construct_webhook_event` when a signature does not verify."""
File without changes
@@ -0,0 +1,158 @@
1
+ """Typed models for the LayerHop API.
2
+
3
+ Responses are plain ``dict`` objects typed as ``TypedDict`` so unknown fields added by newer API
4
+ versions pass through untouched. Keys keep the API's camelCase spelling. Amounts are always
5
+ integer strings in base units -- never floats.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from typing import Any, Dict, List, Literal, Optional, Union
12
+
13
+ if sys.version_info >= (3, 11):
14
+ from typing import NotRequired, TypedDict
15
+ else: # pragma: no cover
16
+ from typing_extensions import NotRequired, TypedDict
17
+
18
+ ProviderId = str
19
+ """``jupiter`` | ``relay`` | ``debridge`` | ``mayan`` | ``zerox`` | ``kyberswap`` | ``odos`` | ``lifi`` | ``mock``"""
20
+
21
+ FeeChannel = Literal["web", "widget", "bot", "api"]
22
+ OrderState = Literal["pending", "filled", "refunded", "failed", "unknown"]
23
+ FINAL_STATES = frozenset({"filled", "refunded", "failed"})
24
+
25
+
26
+ class Chain(TypedDict):
27
+ id: int
28
+ slug: str
29
+ name: str
30
+ vm: Literal["evm", "svm"]
31
+ nativeSymbol: str
32
+ nativeDecimals: int
33
+ explorerTx: str
34
+
35
+
36
+ class Token(TypedDict):
37
+ chainId: int
38
+ address: str
39
+ symbol: str
40
+ decimals: int
41
+
42
+
43
+ class FeeBreakdown(TypedDict):
44
+ ourFeeBps: int
45
+ providerFeeUsd: NotRequired[float]
46
+ gasUsd: NotRequired[float]
47
+ integratorFeeBps: NotRequired[int]
48
+ integrator: NotRequired[str]
49
+
50
+
51
+ class Quote(TypedDict):
52
+ quoteId: str
53
+ provider: ProviderId
54
+ dstAmount: str
55
+ dstAmountMin: str
56
+ estimatedSeconds: int
57
+ fees: FeeBreakdown
58
+ expiresAt: int
59
+
60
+
61
+ class ProviderError(TypedDict):
62
+ provider: str
63
+ code: str
64
+ message: str
65
+
66
+
67
+ class QuoteResponse(TypedDict):
68
+ best: Quote
69
+ quotes: List[Quote]
70
+ errors: List[ProviderError]
71
+
72
+
73
+ class Approval(TypedDict):
74
+ token: str
75
+ spender: str
76
+ amount: str
77
+
78
+
79
+ class EvmTx(TypedDict):
80
+ vm: Literal["evm"]
81
+ chainId: int
82
+ to: str
83
+ data: str
84
+ value: str
85
+ approval: NotRequired[Approval]
86
+
87
+
88
+ class SvmTx(TypedDict):
89
+ vm: Literal["svm"]
90
+ serializedTx: str
91
+ submitViaApi: NotRequired[bool]
92
+
93
+
94
+ BuiltTx = Union[EvmTx, SvmTx]
95
+
96
+
97
+ class OrderStatus(TypedDict):
98
+ provider: ProviderId
99
+ id: str
100
+ state: OrderState
101
+ srcTxHash: NotRequired[str]
102
+ dstTxHash: NotRequired[str]
103
+
104
+
105
+ class ReportSubmittedResponse(TypedDict):
106
+ quoteId: str
107
+ status: str
108
+
109
+
110
+ class SimulateError(TypedDict):
111
+ code: str
112
+ message: str
113
+ logs: NotRequired[List[str]]
114
+
115
+
116
+ class SimulateResponse(TypedDict):
117
+ quoteId: str
118
+ provider: ProviderId
119
+ ok: bool
120
+ vm: Literal["evm", "svm"]
121
+ gasEstimate: NotRequired[str]
122
+ computeUnits: NotRequired[int]
123
+ needsApproval: NotRequired[bool]
124
+ simulated: NotRequired[Literal["tx", "approval"]]
125
+ error: NotRequired[SimulateError]
126
+ tx: BuiltTx
127
+
128
+
129
+ class Order(TypedDict, total=False):
130
+ """A recorded order. Fields follow the API; unknown ones are kept as-is."""
131
+
132
+ quoteId: str
133
+ provider: ProviderId
134
+ status: str
135
+ statusDetail: Optional[str]
136
+ channel: FeeChannel
137
+ ourFeeBps: int
138
+ partnerFeeBps: int
139
+ sender: str
140
+ recipient: str
141
+ srcChainId: int
142
+ srcToken: str
143
+ dstChainId: int
144
+ dstToken: str
145
+ amount: str
146
+ dstAmount: str
147
+ srcTxHash: str
148
+ dstTxHash: str
149
+ createdAt: str
150
+ updatedAt: str
151
+
152
+
153
+ class OrdersPage(TypedDict):
154
+ orders: List[Order]
155
+ nextCursor: NotRequired[Optional[str]]
156
+
157
+
158
+ Json = Dict[str, Any]
@@ -0,0 +1,85 @@
1
+ """Webhook signature verification.
2
+
3
+ LayerHop signs every delivery with the ``LayerHop-Signature`` header::
4
+
5
+ LayerHop-Signature: t=<unix seconds>,v1=<hex hmac_sha256(secret, "<t>.<raw body>")>
6
+
7
+ Always verify against the *raw* request body (bytes as received), before parsing JSON.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import hmac
14
+ import json
15
+ import time
16
+ from typing import Any, Dict, List, Optional, Union
17
+
18
+ from .errors import WebhookSignatureError
19
+
20
+ SIGNATURE_HEADER = "LayerHop-Signature"
21
+ DEFAULT_TOLERANCE_SECONDS = 300
22
+
23
+
24
+ def _as_bytes(v: Union[str, bytes]) -> bytes:
25
+ return v.encode("utf-8") if isinstance(v, str) else v
26
+
27
+
28
+ def _parse_header(header: str) -> "tuple[Optional[int], List[str]]":
29
+ t: Optional[int] = None
30
+ sigs: List[str] = []
31
+ for part in header.split(","):
32
+ k, sep, v = part.strip().partition("=")
33
+ if not sep:
34
+ continue
35
+ if k == "t":
36
+ try:
37
+ t = int(v)
38
+ except ValueError:
39
+ return None, []
40
+ elif k == "v1":
41
+ sigs.append(v.strip())
42
+ return t, sigs
43
+
44
+
45
+ def compute_webhook_signature(secret: Union[str, bytes], timestamp: int, body: Union[str, bytes]) -> str:
46
+ """Hex HMAC-SHA256 of ``"<timestamp>.<body>"`` -- useful for tests of your own endpoint."""
47
+ msg = str(timestamp).encode("ascii") + b"." + _as_bytes(body)
48
+ return hmac.new(_as_bytes(secret), msg, hashlib.sha256).hexdigest()
49
+
50
+
51
+ def verify_webhook_signature(
52
+ secret: Union[str, bytes],
53
+ body: Union[str, bytes],
54
+ header: Optional[str],
55
+ tolerance: int = DEFAULT_TOLERANCE_SECONDS,
56
+ now: Optional[float] = None,
57
+ ) -> bool:
58
+ """Return True when ``header`` is a valid, fresh signature of ``body`` for ``secret``.
59
+
60
+ Rejects timestamps more than ``tolerance`` seconds away from ``now`` (replay protection).
61
+ Accepts any of several ``v1=`` values (secret rotation). Comparison is constant-time.
62
+ """
63
+ if not header:
64
+ return False
65
+ t, sigs = _parse_header(header)
66
+ if t is None or not sigs:
67
+ return False
68
+ current = time.time() if now is None else now
69
+ if abs(current - t) > tolerance:
70
+ return False
71
+ expected = compute_webhook_signature(secret, t, body)
72
+ return any(hmac.compare_digest(expected, s) for s in sigs)
73
+
74
+
75
+ def construct_webhook_event(
76
+ secret: Union[str, bytes],
77
+ body: Union[str, bytes],
78
+ header: Optional[str],
79
+ tolerance: int = DEFAULT_TOLERANCE_SECONDS,
80
+ now: Optional[float] = None,
81
+ ) -> Dict[str, Any]:
82
+ """Verify the signature and return the parsed event, or raise :class:`WebhookSignatureError`."""
83
+ if not verify_webhook_signature(secret, body, header, tolerance=tolerance, now=now):
84
+ raise WebhookSignatureError("Invalid or expired LayerHop-Signature")
85
+ return json.loads(_as_bytes(body))
File without changes
@@ -0,0 +1,54 @@
1
+ import json
2
+ from typing import Any, Callable, Dict, List, Tuple
3
+
4
+ import httpx
5
+ import pytest
6
+
7
+ Route = Tuple[str, str] # (method, path)
8
+
9
+
10
+ class FakeApi:
11
+ """A tiny router over httpx.MockTransport that records every request."""
12
+
13
+ def __init__(self) -> None:
14
+ self.routes: Dict[Route, Callable[[httpx.Request], httpx.Response]] = {}
15
+ self.requests: List[httpx.Request] = []
16
+
17
+ def on(self, method: str, path: str, body: Any = None, status: int = 200, handler: Any = None) -> None:
18
+ if handler is None:
19
+ def handler(_req: httpx.Request, _b: Any = body, _s: int = status) -> httpx.Response:
20
+ return httpx.Response(_s, json=_b)
21
+ self.routes[(method, path)] = handler
22
+
23
+ def __call__(self, req: httpx.Request) -> httpx.Response:
24
+ self.requests.append(req)
25
+ h = self.routes.get((req.method, req.url.raw_path.decode().split("?")[0]))
26
+ if h is None:
27
+ return httpx.Response(404, json={"error": {"code": "NOT_FOUND", "message": "no route", "requestId": "r0"}})
28
+ return h(req)
29
+
30
+ @property
31
+ def last(self) -> httpx.Request:
32
+ return self.requests[-1]
33
+
34
+ def last_json(self) -> Any:
35
+ return json.loads(self.last.content)
36
+
37
+
38
+ @pytest.fixture
39
+ def api() -> FakeApi:
40
+ return FakeApi()
41
+
42
+
43
+ @pytest.fixture
44
+ def anyio_backend() -> str:
45
+ return "asyncio"
46
+
47
+
48
+ QUOTE = {
49
+ "quoteId": "q_1", "provider": "relay", "dstAmount": "612345678", "dstAmountMin": "609283949",
50
+ "estimatedSeconds": 8, "fees": {"ourFeeBps": 20}, "expiresAt": 1760000030,
51
+ }
52
+ QUOTE_RESPONSE = {"best": QUOTE, "quotes": [QUOTE], "errors": []}
53
+ EVM_TX = {"vm": "evm", "chainId": 8453, "to": "0xabc", "data": "0x12", "value": "0",
54
+ "approval": {"token": "0xusdc", "spender": "0xabc", "amount": "100000000"}}
@@ -0,0 +1,174 @@
1
+ import httpx
2
+ import pytest
3
+
4
+ from layerhop import AsyncLayerHop, LayerHop, LayerHopApiError
5
+ from layerhop import client as client_mod
6
+
7
+ from .conftest import EVM_TX, QUOTE_RESPONSE, FakeApi
8
+
9
+
10
+ def make(api: FakeApi, **kw) -> LayerHop:
11
+ return LayerHop(base_url="http://test/", transport=httpx.MockTransport(api), **kw)
12
+
13
+
14
+ def amake(api: FakeApi, **kw) -> AsyncLayerHop:
15
+ return AsyncLayerHop(base_url="http://test", transport=httpx.MockTransport(api), **kw)
16
+
17
+
18
+ def test_default_base_url():
19
+ assert LayerHop().base_url == "https://api.layerhop.com"
20
+
21
+
22
+ def test_health_chains_tokens(api):
23
+ api.on("GET", "/v1/health", {"ok": True, "providers": ["relay"]})
24
+ api.on("GET", "/v1/chains", {"chains": [{"id": 8453, "slug": "base"}]})
25
+ api.on("GET", "/v1/tokens", {"tokens": [{"chainId": 8453, "symbol": "USDC"}]})
26
+ lh = make(api)
27
+ assert lh.health()["ok"] is True
28
+ assert lh.chains()[0]["slug"] == "base"
29
+ assert lh.tokens("base")[0]["symbol"] == "USDC"
30
+ assert api.last.url.params["chain"] == "base"
31
+
32
+
33
+ def test_quote_query_and_api_key(api):
34
+ api.on("GET", "/v1/quote", QUOTE_RESPONSE)
35
+ lh = make(api, api_key="lh_live_x", integrator="myapp", integrator_fee_bps=25)
36
+ r = lh.quote("base", "USDC", "100000000", "solana", "SOL", sender="0xs", recipient="9W")
37
+ assert r["best"]["quoteId"] == "q_1"
38
+ p = api.last.url.params
39
+ assert dict(p) == {
40
+ "srcChain": "base", "srcToken": "USDC", "amount": "100000000", "dstChain": "solana", "dstToken": "SOL",
41
+ "sender": "0xs", "recipient": "9W", "slippageBps": "50", "channel": "api",
42
+ "integrator": "myapp", "integratorFeeBps": "25",
43
+ }
44
+ assert api.last.headers["x-api-key"] == "lh_live_x"
45
+
46
+
47
+ def test_quote_rejects_non_string_amount(api):
48
+ with pytest.raises(TypeError):
49
+ make(api).quote("base", "USDC", 100, "solana", "SOL", sender="a", recipient="b") # type: ignore[arg-type]
50
+
51
+
52
+ def test_quote_without_key_has_no_header(api):
53
+ api.on("GET", "/v1/quote", QUOTE_RESPONSE)
54
+ make(api, channel="widget").quote(8453, "USDC", "1", 7565164, "SOL", sender="a", recipient="b", slippage_bps=100)
55
+ assert "x-api-key" not in api.last.headers
56
+ assert api.last.url.params["channel"] == "widget"
57
+ assert api.last.url.params["slippageBps"] == "100"
58
+ assert api.last.url.params["srcChain"] == "8453"
59
+
60
+
61
+ def test_build_tx_submit_report_simulate(api):
62
+ api.on("POST", "/v1/orders", {"quoteId": "q_1", "provider": "relay", "tx": EVM_TX})
63
+ api.on("POST", "/v1/orders/submit", {"provider": "jupiter", "txHash": "sig"})
64
+ api.on("POST", "/v1/orders/q_1/submitted", {"quoteId": "q_1", "status": "submitted"})
65
+ api.on("POST", "/v1/simulate", {"ok": True, "vm": "evm", "gasEstimate": "21000", "tx": EVM_TX})
66
+ lh = make(api)
67
+ tx = lh.build_tx("q_1")
68
+ assert tx["vm"] == "evm" and tx["approval"]["amount"] == "100000000"
69
+ assert api.last_json() == {"quoteId": "q_1"}
70
+ assert lh.submit("q_1", "AAAA") == "sig"
71
+ assert api.last_json() == {"quoteId": "q_1", "signedTransaction": "AAAA"}
72
+ assert lh.report_submitted("q_1", "0xhash") == {"quoteId": "q_1", "status": "submitted"}
73
+ assert api.last_json() == {"txHash": "0xhash"}
74
+ sim = lh.simulate("q_1")
75
+ assert sim["ok"] and sim["gasEstimate"] == "21000"
76
+ assert api.last_json() == {"quoteId": "q_1"}
77
+
78
+
79
+ def test_status_encodes_id(api):
80
+ api.on("GET", "/v1/orders/relay/a%2Fb", {"provider": "relay", "id": "a/b", "state": "pending"})
81
+ assert make(api).status("relay", "a/b")["state"] == "pending"
82
+
83
+
84
+ def test_list_orders(api):
85
+ api.on("GET", "/v1/orders", {"orders": [{"quoteId": "q_1", "status": "filled", "extra": 1}], "nextCursor": "c2"})
86
+ page = make(api).list_orders(address="0xs", status="filled", limit=10)
87
+ assert page["nextCursor"] == "c2" and page["orders"][0]["extra"] == 1
88
+ assert dict(api.last.url.params) == {"address": "0xs", "status": "filled", "limit": "10"}
89
+ make(api).list_orders()
90
+ assert dict(api.last.url.params) == {}
91
+
92
+
93
+ def test_api_error(api):
94
+ api.on("POST", "/v1/orders", {"error": {"code": "QUOTE_EXPIRED", "message": "Quote expired", "requestId": "rid",
95
+ "docsUrl": "https://docs.layerhop.com/errors/QUOTE_EXPIRED"}}, status=410)
96
+ with pytest.raises(LayerHopApiError) as ei:
97
+ make(api).build_tx("q_old")
98
+ e = ei.value
99
+ assert (e.code, e.status, e.request_id, e.message) == ("QUOTE_EXPIRED", 410, "rid", "Quote expired")
100
+ assert "QUOTE_EXPIRED" in str(e)
101
+
102
+
103
+ def test_api_error_without_body(api):
104
+ api.on("GET", "/v1/health", handler=lambda req: httpx.Response(502, text="bad gateway", headers={"x-request-id": "h1"}))
105
+ with pytest.raises(LayerHopApiError) as ei:
106
+ make(api).health()
107
+ assert ei.value.code == "INTERNAL" and ei.value.status == 502 and ei.value.request_id == "h1"
108
+
109
+
110
+ def _sequence(states):
111
+ it = iter(states)
112
+
113
+ def handler(req):
114
+ s = next(it)
115
+ return httpx.Response(200, json={"provider": "relay", "id": "0xh", "state": s})
116
+ return handler
117
+
118
+
119
+ def test_watch_yields_changes_until_final(api, monkeypatch):
120
+ monkeypatch.setattr(client_mod.time, "sleep", lambda _s: None)
121
+ api.on("GET", "/v1/orders/relay/0xh", handler=_sequence(["unknown", "pending", "pending", "filled", "pending"]))
122
+ states = [s["state"] for s in make(api).watch("relay", "0xh", interval=0)]
123
+ assert states == ["unknown", "pending", "filled"]
124
+ assert len(api.requests) == 4
125
+
126
+
127
+ def test_watch_timeout(api, monkeypatch):
128
+ monkeypatch.setattr(client_mod.time, "sleep", lambda _s: None)
129
+ api.on("GET", "/v1/orders/relay/0xh", {"provider": "relay", "id": "0xh", "state": "pending"})
130
+ assert [s["state"] for s in make(api).watch("relay", "0xh", timeout=0)] == []
131
+
132
+
133
+ def test_context_manager(api):
134
+ api.on("GET", "/v1/health", {"ok": True})
135
+ with make(api) as lh:
136
+ assert lh.health()["ok"]
137
+
138
+
139
+ @pytest.mark.anyio
140
+ async def test_async_client(api):
141
+ api.on("GET", "/v1/chains", {"chains": [{"id": 1}]})
142
+ api.on("GET", "/v1/tokens", {"tokens": []})
143
+ api.on("GET", "/v1/health", {"ok": True})
144
+ api.on("GET", "/v1/quote", QUOTE_RESPONSE)
145
+ api.on("POST", "/v1/orders", {"tx": EVM_TX})
146
+ api.on("POST", "/v1/orders/submit", {"txHash": "sig"})
147
+ api.on("POST", "/v1/orders/q_1/submitted", {"quoteId": "q_1", "status": "submitted"})
148
+ api.on("POST", "/v1/simulate", {"ok": False, "vm": "evm", "error": {"code": "SIMULATION_FAILED", "message": "revert"}, "tx": EVM_TX})
149
+ api.on("GET", "/v1/orders", {"orders": []})
150
+ async with amake(api, api_key="k") as lh:
151
+ assert (await lh.health())["ok"]
152
+ assert (await lh.chains())[0]["id"] == 1
153
+ assert await lh.tokens(1) == []
154
+ q = await lh.quote("base", "USDC", "1", "solana", "SOL", sender="a", recipient="b")
155
+ assert q["best"]["provider"] == "relay"
156
+ assert api.last.headers["x-api-key"] == "k"
157
+ assert (await lh.build_tx("q_1"))["vm"] == "evm"
158
+ assert await lh.submit("q_1", "AA") == "sig"
159
+ assert (await lh.report_submitted("q_1", "0xh"))["status"] == "submitted"
160
+ assert (await lh.simulate("q_1"))["error"]["message"] == "revert"
161
+ assert (await lh.list_orders(address="a"))["orders"] == []
162
+
163
+
164
+ @pytest.mark.anyio
165
+ async def test_async_error_and_watch(api):
166
+ api.on("GET", "/v1/quote", {"error": {"code": "NO_ROUTE", "message": "none", "requestId": "x"}}, status=404)
167
+ api.on("GET", "/v1/orders/debridge/0xh", handler=_sequence(["pending", "refunded"]))
168
+ lh = amake(api)
169
+ with pytest.raises(LayerHopApiError) as ei:
170
+ await lh.quote("base", "USDC", "1", "solana", "SOL", sender="a", recipient="b")
171
+ assert ei.value.code == "NO_ROUTE"
172
+ states = [s["state"] async for s in lh.watch("debridge", "0xh", interval=0)]
173
+ assert states == ["pending", "refunded"]
174
+ await lh.aclose()
@@ -0,0 +1,52 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+
5
+ import pytest
6
+
7
+ from layerhop import WebhookSignatureError, compute_webhook_signature, construct_webhook_event, verify_webhook_signature
8
+
9
+ SECRET = "whsec_test"
10
+ BODY = json.dumps({"id": "evt_1", "type": "order.filled", "createdAt": "2026-10-17T00:00:00Z", "data": {"order": {}}})
11
+ T = 1760000000
12
+
13
+
14
+ def sign(body=BODY, t=T, secret=SECRET):
15
+ return hmac.new(secret.encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
16
+
17
+
18
+ def test_valid_signature_str_and_bytes():
19
+ h = f"t={T},v1={sign()}"
20
+ assert verify_webhook_signature(SECRET, BODY, h, now=T)
21
+ assert verify_webhook_signature(SECRET.encode(), BODY.encode(), h, now=T + 299)
22
+ assert compute_webhook_signature(SECRET, T, BODY) == sign()
23
+
24
+
25
+ def test_rejects_tampered_body_wrong_secret_and_garbage():
26
+ h = f"t={T},v1={sign()}"
27
+ assert not verify_webhook_signature(SECRET, BODY + " ", h, now=T)
28
+ assert not verify_webhook_signature("other", BODY, h, now=T)
29
+ assert not verify_webhook_signature(SECRET, BODY, None, now=T)
30
+ assert not verify_webhook_signature(SECRET, BODY, "", now=T)
31
+ assert not verify_webhook_signature(SECRET, BODY, f"t={T}", now=T)
32
+ assert not verify_webhook_signature(SECRET, BODY, f"v1={sign()}", now=T)
33
+ assert not verify_webhook_signature(SECRET, BODY, f"t=abc,v1={sign()}", now=T)
34
+
35
+
36
+ def test_tolerance():
37
+ h = f"t={T},v1={sign()}"
38
+ assert not verify_webhook_signature(SECRET, BODY, h, now=T + 301)
39
+ assert not verify_webhook_signature(SECRET, BODY, h, now=T - 301)
40
+ assert verify_webhook_signature(SECRET, BODY, h, now=T + 600, tolerance=600)
41
+
42
+
43
+ def test_multiple_v1_values_any_matches():
44
+ h = f"t={T}, v1=deadbeef, v1={sign()}"
45
+ assert verify_webhook_signature(SECRET, BODY, h, now=T)
46
+
47
+
48
+ def test_construct_event():
49
+ ev = construct_webhook_event(SECRET, BODY, f"t={T},v1={sign()}", now=T)
50
+ assert ev["type"] == "order.filled"
51
+ with pytest.raises(WebhookSignatureError):
52
+ construct_webhook_event(SECRET, BODY, f"t={T},v1=00", now=T)