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.
- layerhop-0.1.0/.gitignore +6 -0
- layerhop-0.1.0/LICENSE +21 -0
- layerhop-0.1.0/PKG-INFO +161 -0
- layerhop-0.1.0/README.md +125 -0
- layerhop-0.1.0/pyproject.toml +48 -0
- layerhop-0.1.0/src/layerhop/__init__.py +48 -0
- layerhop-0.1.0/src/layerhop/client.py +292 -0
- layerhop-0.1.0/src/layerhop/errors.py +28 -0
- layerhop-0.1.0/src/layerhop/py.typed +0 -0
- layerhop-0.1.0/src/layerhop/types.py +158 -0
- layerhop-0.1.0/src/layerhop/webhooks.py +85 -0
- layerhop-0.1.0/tests/__init__.py +0 -0
- layerhop-0.1.0/tests/conftest.py +54 -0
- layerhop-0.1.0/tests/test_client.py +174 -0
- layerhop-0.1.0/tests/test_webhooks.py +52 -0
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.
|
layerhop-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/layerhop/) [](https://pypi.org/project/layerhop/) [](https://opensource.org/license/mit) [](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.
|
layerhop-0.1.0/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# layerhop (Python SDK)
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/layerhop/) [](https://pypi.org/project/layerhop/) [](https://opensource.org/license/mit) [](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)
|