layerhop 0.1.0__py3-none-any.whl
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/__init__.py +48 -0
- layerhop/client.py +292 -0
- layerhop/errors.py +28 -0
- layerhop/py.typed +0 -0
- layerhop/types.py +158 -0
- layerhop/webhooks.py +85 -0
- layerhop-0.1.0.dist-info/METADATA +161 -0
- layerhop-0.1.0.dist-info/RECORD +10 -0
- layerhop-0.1.0.dist-info/WHEEL +4 -0
- layerhop-0.1.0.dist-info/licenses/LICENSE +21 -0
layerhop/__init__.py
ADDED
|
@@ -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
|
+
]
|
layerhop/client.py
ADDED
|
@@ -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)
|
layerhop/errors.py
ADDED
|
@@ -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."""
|
layerhop/py.typed
ADDED
|
File without changes
|
layerhop/types.py
ADDED
|
@@ -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]
|
layerhop/webhooks.py
ADDED
|
@@ -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))
|
|
@@ -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.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
layerhop/__init__.py,sha256=8mHLoAk5DwER3gXGm2dmbea7-NazR84LCJUaoVY33XM,1508
|
|
2
|
+
layerhop/client.py,sha256=WofTtkVGb0DhbsAcNCck8BxsLUg6qsPghxfdEo4BA0I,11730
|
|
3
|
+
layerhop/errors.py,sha256=FGVXzcrBZEzoqPjVOC05me3aIOI8DABIaYX-G2vh6eg,975
|
|
4
|
+
layerhop/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
layerhop/types.py,sha256=GOEF98kv2PzPMGhJJ8ee9FTn1sQUSx0S70VghLOXE_A,3334
|
|
6
|
+
layerhop/webhooks.py,sha256=kVqJ3tEMjj64-XzrveUo60lxNtDPEGw7WEGOlj5DeUA,2823
|
|
7
|
+
layerhop-0.1.0.dist-info/METADATA,sha256=O80vkAC-6jlVfNFi4TQE1BHVWy5uDpasXFdU7_LBNZg,6464
|
|
8
|
+
layerhop-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
layerhop-0.1.0.dist-info/licenses/LICENSE,sha256=_J_y1GmXeNcSDq6I16y09E3ihiV7U6v9q2ECFYAYtXI,1065
|
|
10
|
+
layerhop-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|