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.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ target/
4
+ dist/
5
+ node_modules/
6
+ .venv/
7
+ .env.local
8
+ .env.*.local
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.
@@ -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
+ ```
@@ -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
+ )