echozero 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.
- echozero-0.1.0/.gitignore +8 -0
- echozero-0.1.0/LICENSE +21 -0
- echozero-0.1.0/PKG-INFO +57 -0
- echozero-0.1.0/README.md +35 -0
- echozero-0.1.0/pyproject.toml +35 -0
- echozero-0.1.0/src/echozero/__init__.py +28 -0
- echozero-0.1.0/src/echozero/client.py +146 -0
- echozero-0.1.0/src/echozero/hmac.py +85 -0
- echozero-0.1.0/src/echozero/inbound_canonical.py +98 -0
- echozero-0.1.0/src/echozero/signals.py +38 -0
- echozero-0.1.0/src/echozero/websocket.py +48 -0
- echozero-0.1.0/tests/test_hmac.py +131 -0
echozero-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Echo Zero
|
|
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.
|
echozero-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: echozero
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for EchoZero REST, MCP, webhooks, and signal streams.
|
|
5
|
+
Project-URL: Homepage, https://docs.echozero.app/guides/sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/EchoZeroApp/echozero-sdk
|
|
7
|
+
Project-URL: Issues, https://github.com/EchoZeroApp/echozero-sdk/issues
|
|
8
|
+
Author-email: Echo Zero <support@echozero.app>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: copy-trading,echozero,hmac,hyperliquid,solana,trading,webhooks
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: requests>=2.31.0
|
|
18
|
+
Requires-Dist: websocket-client>=1.8.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# echozero
|
|
24
|
+
|
|
25
|
+
MIT-licensed Python SDK for EchoZero.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install echozero
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## REST Client
|
|
34
|
+
|
|
35
|
+
```py
|
|
36
|
+
import os
|
|
37
|
+
from echozero import EchoZeroClient
|
|
38
|
+
|
|
39
|
+
client = EchoZeroClient(
|
|
40
|
+
base_url="https://mcp.echozero.app",
|
|
41
|
+
api_key=os.environ["ECHOZERO_API_KEY"],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
print(client.get("/api/v1/users/me"))
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Inbound Webhook Signing
|
|
48
|
+
|
|
49
|
+
```py
|
|
50
|
+
from echozero import sign_inbound_webhook
|
|
51
|
+
|
|
52
|
+
body = {"text": "BUY SOL 500 USDC"}
|
|
53
|
+
headers = sign_inbound_webhook(
|
|
54
|
+
signing_secret="ezw_secret",
|
|
55
|
+
body=body,
|
|
56
|
+
)
|
|
57
|
+
```
|
echozero-0.1.0/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# echozero
|
|
2
|
+
|
|
3
|
+
MIT-licensed Python SDK for EchoZero.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install echozero
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## REST Client
|
|
12
|
+
|
|
13
|
+
```py
|
|
14
|
+
import os
|
|
15
|
+
from echozero import EchoZeroClient
|
|
16
|
+
|
|
17
|
+
client = EchoZeroClient(
|
|
18
|
+
base_url="https://mcp.echozero.app",
|
|
19
|
+
api_key=os.environ["ECHOZERO_API_KEY"],
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
print(client.get("/api/v1/users/me"))
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Inbound Webhook Signing
|
|
26
|
+
|
|
27
|
+
```py
|
|
28
|
+
from echozero import sign_inbound_webhook
|
|
29
|
+
|
|
30
|
+
body = {"text": "BUY SOL 500 USDC"}
|
|
31
|
+
headers = sign_inbound_webhook(
|
|
32
|
+
signing_secret="ezw_secret",
|
|
33
|
+
body=body,
|
|
34
|
+
)
|
|
35
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "echozero"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for EchoZero REST, MCP, webhooks, and signal streams."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Echo Zero", email = "support@echozero.app" }]
|
|
14
|
+
keywords = ["echozero", "trading", "copy-trading", "webhooks", "hmac", "solana", "hyperliquid"]
|
|
15
|
+
dependencies = ["requests>=2.31.0", "websocket-client>=1.8.0"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12"
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://docs.echozero.app/guides/sdk"
|
|
25
|
+
Repository = "https://github.com/EchoZeroApp/echozero-sdk"
|
|
26
|
+
Issues = "https://github.com/EchoZeroApp/echozero-sdk/issues"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = ["pytest>=8.0.0"]
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel]
|
|
32
|
+
packages = ["src/echozero"]
|
|
33
|
+
|
|
34
|
+
[tool.pytest.ini_options]
|
|
35
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .client import EchoZeroApiError, EchoZeroClient
|
|
2
|
+
from .hmac import (
|
|
3
|
+
canonical_webhook_body,
|
|
4
|
+
sign_inbound_webhook,
|
|
5
|
+
sign_rest_request,
|
|
6
|
+
stable_json,
|
|
7
|
+
verify_inbound_webhook,
|
|
8
|
+
verify_outbound_webhook,
|
|
9
|
+
)
|
|
10
|
+
from .inbound_canonical import inbound_webhook_canonical_json, rest_request_body_text
|
|
11
|
+
from .signals import AgentSignalResponse, OutboundExecutionWebhook
|
|
12
|
+
from .websocket import EchoZeroSignalClient
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AgentSignalResponse",
|
|
16
|
+
"EchoZeroApiError",
|
|
17
|
+
"EchoZeroClient",
|
|
18
|
+
"EchoZeroSignalClient",
|
|
19
|
+
"OutboundExecutionWebhook",
|
|
20
|
+
"canonical_webhook_body",
|
|
21
|
+
"inbound_webhook_canonical_json",
|
|
22
|
+
"rest_request_body_text",
|
|
23
|
+
"sign_inbound_webhook",
|
|
24
|
+
"sign_rest_request",
|
|
25
|
+
"stable_json",
|
|
26
|
+
"verify_inbound_webhook",
|
|
27
|
+
"verify_outbound_webhook",
|
|
28
|
+
]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from .hmac import sign_inbound_webhook, sign_rest_request
|
|
8
|
+
from .inbound_canonical import rest_request_body_text
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EchoZeroApiError(RuntimeError):
|
|
12
|
+
def __init__(self, message: str, status: int, code: str | None = None, payload: Any = None):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.status = status
|
|
15
|
+
self.code = code
|
|
16
|
+
self.payload = payload
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EchoZeroClient:
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
*,
|
|
23
|
+
base_url: str = "https://mcp.echozero.app",
|
|
24
|
+
api_key: str | None = None,
|
|
25
|
+
bearer_token: str | None = None,
|
|
26
|
+
hmac_secret_key: str | None = None,
|
|
27
|
+
session: requests.Session | None = None,
|
|
28
|
+
):
|
|
29
|
+
self.base_url = base_url.rstrip("/")
|
|
30
|
+
self.api_key = api_key
|
|
31
|
+
self.bearer_token = bearer_token
|
|
32
|
+
self.hmac_secret_key = hmac_secret_key
|
|
33
|
+
self.session = session or requests.Session()
|
|
34
|
+
|
|
35
|
+
def get(self, path: str, **kwargs: Any) -> Any:
|
|
36
|
+
return self.request("GET", path, **kwargs)
|
|
37
|
+
|
|
38
|
+
def post(self, path: str, json_body: Any | None = None, **kwargs: Any) -> Any:
|
|
39
|
+
return self.request("POST", path, json_body=json_body, **kwargs)
|
|
40
|
+
|
|
41
|
+
def patch(self, path: str, json_body: Any | None = None, **kwargs: Any) -> Any:
|
|
42
|
+
return self.request("PATCH", path, json_body=json_body, **kwargs)
|
|
43
|
+
|
|
44
|
+
def delete(self, path: str, **kwargs: Any) -> Any:
|
|
45
|
+
return self.request("DELETE", path, **kwargs)
|
|
46
|
+
|
|
47
|
+
def post_agent_signal(
|
|
48
|
+
self,
|
|
49
|
+
agent_id: str,
|
|
50
|
+
body: Mapping[str, Any],
|
|
51
|
+
signing_secret: str,
|
|
52
|
+
) -> Any:
|
|
53
|
+
webhook_headers = sign_inbound_webhook(signing_secret=signing_secret, body=body)
|
|
54
|
+
return self.post(
|
|
55
|
+
f"/api/public/agent-signals/{agent_id}",
|
|
56
|
+
json_body=dict(body),
|
|
57
|
+
headers={
|
|
58
|
+
"X-EZ-Timestamp": webhook_headers["X-EZ-Timestamp"],
|
|
59
|
+
"X-EZ-Signature": webhook_headers["X-EZ-Signature"],
|
|
60
|
+
},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def request(
|
|
64
|
+
self,
|
|
65
|
+
method: str,
|
|
66
|
+
path: str,
|
|
67
|
+
*,
|
|
68
|
+
query: Mapping[str, Any] | None = None,
|
|
69
|
+
json_body: Any | None = None,
|
|
70
|
+
headers: Mapping[str, str] | None = None,
|
|
71
|
+
hmac: bool = False,
|
|
72
|
+
) -> Any:
|
|
73
|
+
url = self._url(path, query)
|
|
74
|
+
request_headers = {"Accept": "application/json", **(headers or {})}
|
|
75
|
+
if self.bearer_token:
|
|
76
|
+
request_headers["Authorization"] = f"Bearer {self.bearer_token}"
|
|
77
|
+
elif self.api_key:
|
|
78
|
+
request_headers["x-api-key"] = self.api_key
|
|
79
|
+
|
|
80
|
+
body_bytes: bytes | None = None
|
|
81
|
+
body_text = ""
|
|
82
|
+
if json_body is not None:
|
|
83
|
+
body_text = rest_request_body_text(json_body)
|
|
84
|
+
body_bytes = body_text.encode("utf-8")
|
|
85
|
+
request_headers["Content-Type"] = "application/json"
|
|
86
|
+
|
|
87
|
+
if hmac:
|
|
88
|
+
if not self.hmac_secret_key:
|
|
89
|
+
raise ValueError("hmac_secret_key is required when hmac=True")
|
|
90
|
+
request_headers.update(
|
|
91
|
+
sign_rest_request(
|
|
92
|
+
secret_key=self.hmac_secret_key,
|
|
93
|
+
method=method,
|
|
94
|
+
path=self._path_with_query(path, query),
|
|
95
|
+
body=body_text if json_body is not None else None,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
response = self.session.request(
|
|
100
|
+
method.upper(),
|
|
101
|
+
url,
|
|
102
|
+
data=body_bytes,
|
|
103
|
+
headers=request_headers,
|
|
104
|
+
)
|
|
105
|
+
payload = self._read_json(response)
|
|
106
|
+
if not response.ok:
|
|
107
|
+
error = payload.get("error", {}) if isinstance(payload, dict) else {}
|
|
108
|
+
raise EchoZeroApiError(
|
|
109
|
+
error.get("message") or response.reason,
|
|
110
|
+
response.status_code,
|
|
111
|
+
error.get("code"),
|
|
112
|
+
payload,
|
|
113
|
+
)
|
|
114
|
+
if isinstance(payload, dict) and payload.get("success") is True and "data" in payload:
|
|
115
|
+
return payload["data"]
|
|
116
|
+
return payload
|
|
117
|
+
|
|
118
|
+
def _url(self, path: str, query: Mapping[str, Any] | None = None) -> str:
|
|
119
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
120
|
+
base = path
|
|
121
|
+
else:
|
|
122
|
+
base = f"{self.base_url}{path if path.startswith('/') else f'/{path}'}"
|
|
123
|
+
from urllib.parse import urlencode
|
|
124
|
+
|
|
125
|
+
query_string = urlencode({k: v for k, v in (query or {}).items() if v is not None})
|
|
126
|
+
return f"{base}?{query_string}" if query_string else base
|
|
127
|
+
|
|
128
|
+
def _path_with_query(self, path: str, query: Mapping[str, Any] | None = None) -> str:
|
|
129
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
130
|
+
from urllib.parse import urlparse
|
|
131
|
+
|
|
132
|
+
parsed = urlparse(path)
|
|
133
|
+
path = parsed.path + (f"?{parsed.query}" if parsed.query else "")
|
|
134
|
+
from urllib.parse import urlencode
|
|
135
|
+
|
|
136
|
+
query_string = urlencode({k: v for k, v in (query or {}).items() if v is not None})
|
|
137
|
+
return f"{path}?{query_string}" if query_string else path
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _read_json(response: requests.Response) -> Any:
|
|
141
|
+
if not response.text:
|
|
142
|
+
return None
|
|
143
|
+
try:
|
|
144
|
+
return response.json()
|
|
145
|
+
except ValueError:
|
|
146
|
+
return response.text
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
from .inbound_canonical import inbound_webhook_canonical_json, rest_request_body_text
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def stable_json(value: Any) -> str:
|
|
13
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _hmac_sha256_hex(secret: str, payload: str) -> str:
|
|
17
|
+
return hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sign_rest_request(
|
|
21
|
+
*,
|
|
22
|
+
secret_key: str,
|
|
23
|
+
method: str,
|
|
24
|
+
path: str,
|
|
25
|
+
body: Any | None = None,
|
|
26
|
+
timestamp_ms: int | None = None,
|
|
27
|
+
) -> dict[str, str]:
|
|
28
|
+
timestamp = str(timestamp_ms or int(time.time() * 1000))
|
|
29
|
+
body_text = rest_request_body_text(body)
|
|
30
|
+
payload = f"{timestamp}{method.upper()}{path}{body_text}"
|
|
31
|
+
return {
|
|
32
|
+
"x-timestamp": timestamp,
|
|
33
|
+
"x-signature": _hmac_sha256_hex(secret_key, payload),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def canonical_webhook_body(body: Mapping[str, Any]) -> str:
|
|
38
|
+
return inbound_webhook_canonical_json(body)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def sign_inbound_webhook(
|
|
42
|
+
*,
|
|
43
|
+
signing_secret: str,
|
|
44
|
+
body: Mapping[str, Any],
|
|
45
|
+
timestamp_seconds: int | None = None,
|
|
46
|
+
) -> dict[str, str]:
|
|
47
|
+
timestamp = str(timestamp_seconds or int(time.time()))
|
|
48
|
+
canonical = inbound_webhook_canonical_json(body)
|
|
49
|
+
return {
|
|
50
|
+
"X-EZ-Timestamp": timestamp,
|
|
51
|
+
"X-EZ-Signature": _hmac_sha256_hex(signing_secret, f"{timestamp}.{canonical}"),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def verify_inbound_webhook(
|
|
56
|
+
*,
|
|
57
|
+
signing_secret: str,
|
|
58
|
+
body: Mapping[str, Any],
|
|
59
|
+
timestamp_seconds: int | str,
|
|
60
|
+
signature: str,
|
|
61
|
+
max_skew_seconds: int = 300,
|
|
62
|
+
) -> bool:
|
|
63
|
+
try:
|
|
64
|
+
timestamp = int(timestamp_seconds)
|
|
65
|
+
except (TypeError, ValueError):
|
|
66
|
+
return False
|
|
67
|
+
if abs(int(time.time()) - timestamp) > max_skew_seconds:
|
|
68
|
+
return False
|
|
69
|
+
expected = sign_inbound_webhook(
|
|
70
|
+
signing_secret=signing_secret,
|
|
71
|
+
body=body,
|
|
72
|
+
timestamp_seconds=timestamp,
|
|
73
|
+
)["X-EZ-Signature"]
|
|
74
|
+
return hmac.compare_digest(expected, signature.lower())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def verify_outbound_webhook(
|
|
78
|
+
*,
|
|
79
|
+
secret_key: str,
|
|
80
|
+
raw_body: str,
|
|
81
|
+
timestamp: str,
|
|
82
|
+
signature: str,
|
|
83
|
+
) -> bool:
|
|
84
|
+
expected = _hmac_sha256_hex(secret_key, f"{timestamp}.{raw_body}")
|
|
85
|
+
return hmac.compare_digest(expected, signature.lower())
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Mirrors `agentInboundWebhook.signing.ts` in mcp-main."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Mapping
|
|
7
|
+
|
|
8
|
+
STRUCTURED_SIGNING_KEYS = (
|
|
9
|
+
"action",
|
|
10
|
+
"amount",
|
|
11
|
+
"chain",
|
|
12
|
+
"changes",
|
|
13
|
+
"clientTimestamp",
|
|
14
|
+
"confidence",
|
|
15
|
+
"context",
|
|
16
|
+
"currentPnlPct",
|
|
17
|
+
"entryPrice",
|
|
18
|
+
"entryZone",
|
|
19
|
+
"eventType",
|
|
20
|
+
"exitPrice",
|
|
21
|
+
"exitReason",
|
|
22
|
+
"expiresAt",
|
|
23
|
+
"ideaId",
|
|
24
|
+
"instrument",
|
|
25
|
+
"leverageX",
|
|
26
|
+
"metadata",
|
|
27
|
+
"orderType",
|
|
28
|
+
"pnlPct",
|
|
29
|
+
"positionRef",
|
|
30
|
+
"reasoning",
|
|
31
|
+
"relatedTradeId",
|
|
32
|
+
"riskMgmt",
|
|
33
|
+
"sellSizePct",
|
|
34
|
+
"side",
|
|
35
|
+
"symbol",
|
|
36
|
+
"tokenAddress",
|
|
37
|
+
"tradeType",
|
|
38
|
+
"triggers",
|
|
39
|
+
"urgency",
|
|
40
|
+
"version",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _canonicalize_value(value: Any) -> Any | None:
|
|
45
|
+
if value is None:
|
|
46
|
+
return None
|
|
47
|
+
if isinstance(value, bool):
|
|
48
|
+
return value
|
|
49
|
+
if isinstance(value, str):
|
|
50
|
+
return value
|
|
51
|
+
if isinstance(value, (int, float)):
|
|
52
|
+
if isinstance(value, float) and (value != value or value in (float("inf"), float("-inf"))):
|
|
53
|
+
return None
|
|
54
|
+
return value
|
|
55
|
+
if isinstance(value, list):
|
|
56
|
+
return [_canonicalize_value(item) for item in value]
|
|
57
|
+
if isinstance(value, dict):
|
|
58
|
+
out: dict[str, Any] = {}
|
|
59
|
+
for key in sorted(value):
|
|
60
|
+
child = _canonicalize_value(value[key])
|
|
61
|
+
if child is not None or value[key] is None:
|
|
62
|
+
out[key] = child
|
|
63
|
+
return out
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def inbound_webhook_canonical_json(body: Mapping[str, Any]) -> str:
|
|
68
|
+
sorted_body: dict[str, Any] = {}
|
|
69
|
+
|
|
70
|
+
if "text" in body and body["text"] is not None:
|
|
71
|
+
sorted_body["text"] = body["text"]
|
|
72
|
+
|
|
73
|
+
idk = body.get("idempotencyKey")
|
|
74
|
+
if isinstance(idk, str):
|
|
75
|
+
trimmed = idk.strip()
|
|
76
|
+
if trimmed:
|
|
77
|
+
sorted_body["idempotencyKey"] = trimmed
|
|
78
|
+
|
|
79
|
+
for key in STRUCTURED_SIGNING_KEYS:
|
|
80
|
+
if key not in body:
|
|
81
|
+
continue
|
|
82
|
+
value = _canonicalize_value(body[key])
|
|
83
|
+
if value is not None or body[key] is None:
|
|
84
|
+
sorted_body[key] = value
|
|
85
|
+
|
|
86
|
+
return json.dumps(
|
|
87
|
+
{key: sorted_body[key] for key in sorted(sorted_body)},
|
|
88
|
+
separators=(",", ":"),
|
|
89
|
+
ensure_ascii=False,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def rest_request_body_text(body: Any | None) -> str:
|
|
94
|
+
if body is None:
|
|
95
|
+
return ""
|
|
96
|
+
if isinstance(body, str):
|
|
97
|
+
return body
|
|
98
|
+
return json.dumps(body, separators=(",", ":"), ensure_ascii=False)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Structured signal envelope types (#1098)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal, TypedDict
|
|
6
|
+
|
|
7
|
+
StructuredSignalEventType = Literal[
|
|
8
|
+
"trade_idea",
|
|
9
|
+
"buy",
|
|
10
|
+
"scale_in",
|
|
11
|
+
"sell",
|
|
12
|
+
"partial_sell",
|
|
13
|
+
"amend",
|
|
14
|
+
"breakeven",
|
|
15
|
+
"cancel",
|
|
16
|
+
"position_update",
|
|
17
|
+
"trade_review",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
StructuredSignalChain = Literal["solana", "hyperliquid"]
|
|
21
|
+
StructuredSignalSide = Literal["long", "short"]
|
|
22
|
+
StructuredSignalTradeType = Literal["spot", "perp", "virtual"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AgentSignalResponse(TypedDict, total=False):
|
|
26
|
+
outcome: Literal["matched", "unmatched", "skipped", "error"]
|
|
27
|
+
signalId: str
|
|
28
|
+
status: str
|
|
29
|
+
skipReason: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OutboundExecutionWebhook(TypedDict, total=False):
|
|
33
|
+
event: Literal["signal.execution", "signal.execution.failed"]
|
|
34
|
+
signalId: str
|
|
35
|
+
developerAgentId: str
|
|
36
|
+
status: str
|
|
37
|
+
executionResult: dict[str, object]
|
|
38
|
+
timestamp: str
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
|
|
7
|
+
from websocket import WebSocketApp
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EchoZeroSignalClient:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
*,
|
|
14
|
+
url: str,
|
|
15
|
+
api_key: str | None = None,
|
|
16
|
+
bearer_token: str | None = None,
|
|
17
|
+
):
|
|
18
|
+
params = {}
|
|
19
|
+
if api_key:
|
|
20
|
+
params["api_key"] = api_key
|
|
21
|
+
if bearer_token:
|
|
22
|
+
params["access_token"] = bearer_token
|
|
23
|
+
query = urlencode(params)
|
|
24
|
+
self.url = f"{url}?{query}" if query else url
|
|
25
|
+
self.socket: WebSocketApp | None = None
|
|
26
|
+
|
|
27
|
+
def connect(
|
|
28
|
+
self,
|
|
29
|
+
on_message: Callable[[Any], None],
|
|
30
|
+
on_error: Callable[[Exception], None] | None = None,
|
|
31
|
+
) -> WebSocketApp:
|
|
32
|
+
def handle_message(_: WebSocketApp, message: str) -> None:
|
|
33
|
+
try:
|
|
34
|
+
on_message(json.loads(message))
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
on_message(message)
|
|
37
|
+
|
|
38
|
+
self.socket = WebSocketApp(
|
|
39
|
+
self.url,
|
|
40
|
+
on_message=handle_message,
|
|
41
|
+
on_error=lambda _, err: on_error(err) if on_error else None,
|
|
42
|
+
)
|
|
43
|
+
return self.socket
|
|
44
|
+
|
|
45
|
+
def send_signal(self, signal: Any) -> None:
|
|
46
|
+
if not self.socket:
|
|
47
|
+
raise RuntimeError("Signal WebSocket is not connected")
|
|
48
|
+
self.socket.send(json.dumps({"event": "signal", "data": signal}))
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from echozero.hmac import (
|
|
6
|
+
sign_inbound_webhook,
|
|
7
|
+
sign_rest_request,
|
|
8
|
+
verify_inbound_webhook,
|
|
9
|
+
verify_outbound_webhook,
|
|
10
|
+
)
|
|
11
|
+
from echozero.inbound_canonical import inbound_webhook_canonical_json, rest_request_body_text
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_sign_rest_request_matches_backend_vector():
|
|
15
|
+
headers = sign_rest_request(
|
|
16
|
+
secret_key="test_secret",
|
|
17
|
+
method="POST",
|
|
18
|
+
path="/api/api-keys",
|
|
19
|
+
body={"name": "SDK HMAC Test"},
|
|
20
|
+
timestamp_ms=1_710_000_000_000,
|
|
21
|
+
)
|
|
22
|
+
assert (
|
|
23
|
+
headers["x-signature"]
|
|
24
|
+
== "274c9ff280eadf751530e9e7fce2c2a573d8676b13b7108fe353407df7cc9e00"
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_rest_request_body_text_matches_signed_bytes():
|
|
29
|
+
body = {"zzz": "y", "name": "x"}
|
|
30
|
+
body_text = rest_request_body_text(body)
|
|
31
|
+
assert body_text == json.dumps(body, separators=(",", ":"), ensure_ascii=False)
|
|
32
|
+
headers_a = sign_rest_request(
|
|
33
|
+
secret_key="secret",
|
|
34
|
+
method="POST",
|
|
35
|
+
path="/api/api-keys",
|
|
36
|
+
body=body_text,
|
|
37
|
+
timestamp_ms=123,
|
|
38
|
+
)
|
|
39
|
+
headers_b = sign_rest_request(
|
|
40
|
+
secret_key="secret",
|
|
41
|
+
method="POST",
|
|
42
|
+
path="/api/api-keys",
|
|
43
|
+
body=body,
|
|
44
|
+
timestamp_ms=123,
|
|
45
|
+
)
|
|
46
|
+
assert headers_a == headers_b
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_inbound_canonical_strips_unknown_fields():
|
|
50
|
+
assert (
|
|
51
|
+
inbound_webhook_canonical_json({"text": "BUY SOL", "extraField": "ignored"})
|
|
52
|
+
== '{"text":"BUY SOL"}'
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_sign_inbound_webhook_matches_backend_vector():
|
|
57
|
+
headers = sign_inbound_webhook(
|
|
58
|
+
signing_secret="test_secret",
|
|
59
|
+
body={"text": "BUY SOL 500 USDC", "idempotencyKey": "sdk-test-1"},
|
|
60
|
+
timestamp_seconds=1_710_000_000,
|
|
61
|
+
)
|
|
62
|
+
assert (
|
|
63
|
+
headers["X-EZ-Signature"]
|
|
64
|
+
== "1d30d896fc609e62bcf9be991c1dc1177a9c63219909869ef2846bad4685de3b"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_sign_inbound_webhook_ignores_unknown_fields():
|
|
69
|
+
body = {
|
|
70
|
+
"eventType": "buy",
|
|
71
|
+
"idempotencyKey": "test-1",
|
|
72
|
+
"reasoning": "test",
|
|
73
|
+
"tokenAddress": "So11111111111111111111111111111111111111112",
|
|
74
|
+
"amount": 500,
|
|
75
|
+
}
|
|
76
|
+
with_extra = {**body, "unknownField": "strip me"}
|
|
77
|
+
a = sign_inbound_webhook(
|
|
78
|
+
signing_secret="secret", body=body, timestamp_seconds=1_710_000_000
|
|
79
|
+
)
|
|
80
|
+
b = sign_inbound_webhook(
|
|
81
|
+
signing_secret="secret", body=with_extra, timestamp_seconds=1_710_000_000
|
|
82
|
+
)
|
|
83
|
+
assert a == b
|
|
84
|
+
assert (
|
|
85
|
+
a["X-EZ-Signature"]
|
|
86
|
+
== "be420f61d91e6b871481774c62f972f0aafa6f5f1a727ba1e4a32558784f77c3"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_verify_inbound_webhook_accepts_valid_signature():
|
|
91
|
+
body = {"text": "BUY SOL 500 USDC", "idempotencyKey": "sdk-test-1"}
|
|
92
|
+
headers = sign_inbound_webhook(
|
|
93
|
+
signing_secret="test_secret",
|
|
94
|
+
body=body,
|
|
95
|
+
timestamp_seconds=1_710_000_000,
|
|
96
|
+
)
|
|
97
|
+
assert verify_inbound_webhook(
|
|
98
|
+
signing_secret="test_secret",
|
|
99
|
+
body=body,
|
|
100
|
+
timestamp_seconds=headers["X-EZ-Timestamp"],
|
|
101
|
+
signature=headers["X-EZ-Signature"],
|
|
102
|
+
max_skew_seconds=999_999_999,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_verify_outbound_webhook():
|
|
107
|
+
raw_body = json.dumps(
|
|
108
|
+
{
|
|
109
|
+
"event": "signal.execution",
|
|
110
|
+
"signalId": "sig_1",
|
|
111
|
+
"developerAgentId": "agent_1",
|
|
112
|
+
"status": "executed",
|
|
113
|
+
"timestamp": "2026-07-06T12:00:00.000Z",
|
|
114
|
+
},
|
|
115
|
+
separators=(",", ":"),
|
|
116
|
+
)
|
|
117
|
+
timestamp = "2026-07-06T12:00:00.000Z"
|
|
118
|
+
import hashlib
|
|
119
|
+
import hmac as hmac_mod
|
|
120
|
+
|
|
121
|
+
signature = hmac_mod.new(
|
|
122
|
+
b"webhook_secret",
|
|
123
|
+
f"{timestamp}.{raw_body}".encode(),
|
|
124
|
+
hashlib.sha256,
|
|
125
|
+
).hexdigest()
|
|
126
|
+
assert verify_outbound_webhook(
|
|
127
|
+
secret_key="webhook_secret",
|
|
128
|
+
raw_body=raw_body,
|
|
129
|
+
timestamp=timestamp,
|
|
130
|
+
signature=signature,
|
|
131
|
+
)
|