ghostgate-sdk 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ghost Protocol
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,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: ghostgate-sdk
3
+ Version: 0.1.0
4
+ Summary: Ghost Protocol Python SDK for gate access and telemetry.
5
+ Author: Ghost Protocol
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://ghostprotocol.cc
8
+ Project-URL: Repository, https://github.com/Ghost-Protocol-Infrastructure/GHOST_PROTOCOL
9
+ Project-URL: Documentation, https://ghostprotocol.cc/docs
10
+ Project-URL: Issues, https://github.com/Ghost-Protocol-Infrastructure/GHOST_PROTOCOL/issues
11
+ Keywords: ghostgate,ghost protocol,web3,api,sdk,telemetry
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: requests>=2.31.0
23
+ Requires-Dist: eth-account>=0.13.0
24
+ Dynamic: license-file
25
+
26
+ # GhostGate Python SDK
27
+
28
+ Python SDK for Ghost Protocol gate access and telemetry.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install ghostgate-sdk
34
+ ```
35
+
36
+ ## Quickstart
37
+
38
+ ```python
39
+ import os
40
+ from ghostgate import GhostGate
41
+
42
+ sdk = GhostGate(
43
+ api_key=os.environ["GHOST_API_KEY"],
44
+ private_key=os.environ["GHOST_SIGNER_PRIVATE_KEY"],
45
+ base_url=os.getenv("GHOST_GATE_BASE_URL", "https://ghostprotocol.cc"),
46
+ chain_id=8453,
47
+ service_slug="agent-18755",
48
+ credit_cost=1,
49
+ )
50
+
51
+ result = sdk.connect()
52
+ print(result)
53
+ ```
54
+
55
+ ## Canonical methods
56
+
57
+ - `connect(...)`
58
+ - `pulse(...)`
59
+ - `outcome(...)`
60
+ - `start_heartbeat(...)`
61
+
62
+ Backward-compatible aliases are also available:
63
+
64
+ - `send_pulse(...)`
65
+ - `report_consumer_outcome(...)`
66
+
67
+ ## Security note
68
+
69
+ Use signer private keys only in trusted backend/server/CLI environments. Never expose private keys in frontend code.
70
+
@@ -0,0 +1,45 @@
1
+ # GhostGate Python SDK
2
+
3
+ Python SDK for Ghost Protocol gate access and telemetry.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install ghostgate-sdk
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ import os
15
+ from ghostgate import GhostGate
16
+
17
+ sdk = GhostGate(
18
+ api_key=os.environ["GHOST_API_KEY"],
19
+ private_key=os.environ["GHOST_SIGNER_PRIVATE_KEY"],
20
+ base_url=os.getenv("GHOST_GATE_BASE_URL", "https://ghostprotocol.cc"),
21
+ chain_id=8453,
22
+ service_slug="agent-18755",
23
+ credit_cost=1,
24
+ )
25
+
26
+ result = sdk.connect()
27
+ print(result)
28
+ ```
29
+
30
+ ## Canonical methods
31
+
32
+ - `connect(...)`
33
+ - `pulse(...)`
34
+ - `outcome(...)`
35
+ - `start_heartbeat(...)`
36
+
37
+ Backward-compatible aliases are also available:
38
+
39
+ - `send_pulse(...)`
40
+ - `report_consumer_outcome(...)`
41
+
42
+ ## Security note
43
+
44
+ Use signer private keys only in trusted backend/server/CLI environments. Never expose private keys in frontend code.
45
+
@@ -0,0 +1,574 @@
1
+ """Ghost Protocol fulfillment helpers (Python SDK parity module).
2
+
3
+ This is an additive MVP helper module for local/server integrations.
4
+ It supports:
5
+ - consumer ticket issuance + direct merchant execute
6
+ - merchant ticket verification
7
+ - merchant capture completion
8
+ - fulfillment transport header helpers
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import time
18
+ import uuid
19
+ from dataclasses import dataclass
20
+ from typing import Any, Mapping, Optional
21
+ from urllib.parse import quote
22
+
23
+ import requests
24
+ from eth_account import Account
25
+ from eth_account.messages import encode_typed_data
26
+
27
+ FULFILLMENT_API_VERSION = 1
28
+ FULFILLMENT_DOMAIN_NAME = "GhostGateFulfillment"
29
+ FULFILLMENT_DOMAIN_VERSION = "1"
30
+ FULFILLMENT_DOMAIN_VERIFYING_CONTRACT = "0x0000000000000000000000000000000000000000"
31
+ FULFILLMENT_DEFAULT_CHAIN_ID = 8453
32
+ FULFILLMENT_ZERO_HASH_32 = "0x" + ("00" * 32)
33
+
34
+ HEADER_TICKET_VERSION = "x-ghost-fulfillment-ticket-version"
35
+ HEADER_TICKET_PAYLOAD = "x-ghost-fulfillment-ticket"
36
+ HEADER_TICKET_SIGNATURE = "x-ghost-fulfillment-ticket-sig"
37
+ HEADER_TICKET_ID = "x-ghost-fulfillment-ticket-id"
38
+ HEADER_CLIENT_REQUEST_ID = "x-ghost-fulfillment-client-request-id"
39
+ DEFAULT_FULFILLMENT_PROTOCOL_SIGNER_ADDRESSES = [
40
+ "0xf879f5e26aa52663887f97a51d3444afef8df3fc",
41
+ ]
42
+
43
+
44
+ def _normalize_base_url(value: str) -> str:
45
+ return value.rstrip("/")
46
+
47
+
48
+ def _sha256_hex_utf8(value: str) -> str:
49
+ return "0x" + hashlib.sha256(value.encode("utf-8")).hexdigest()
50
+
51
+
52
+ def _canonicalize_json(value: Any) -> str:
53
+ if value is None:
54
+ return "null"
55
+ if isinstance(value, bool):
56
+ return "true" if value else "false"
57
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
58
+ if isinstance(value, float) and (value != value or value in (float("inf"), float("-inf"))):
59
+ raise ValueError("Non-finite numbers are not allowed in canonical JSON.")
60
+ return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
61
+ if isinstance(value, str):
62
+ return json.dumps(value, separators=(",", ":"), ensure_ascii=False)
63
+ if isinstance(value, list):
64
+ return "[" + ",".join(_canonicalize_json(item) for item in value) + "]"
65
+ if isinstance(value, dict):
66
+ keys = sorted(value.keys())
67
+ parts = []
68
+ for key in keys:
69
+ if not isinstance(key, str):
70
+ raise ValueError("Canonical JSON only supports string object keys.")
71
+ entry_value = value[key]
72
+ if callable(entry_value):
73
+ raise ValueError(f"Unsupported callable at key '{key}'")
74
+ parts.append(json.dumps(key, separators=(",", ":"), ensure_ascii=False) + ":" + _canonicalize_json(entry_value))
75
+ return "{" + ",".join(parts) + "}"
76
+ raise ValueError(f"Unsupported value type for canonical JSON: {type(value).__name__}")
77
+
78
+
79
+ def hash_canonical_fulfillment_body_json(payload: Any) -> str:
80
+ return _sha256_hex_utf8(_canonicalize_json(payload))
81
+
82
+
83
+ def _decode_form_component(value: str) -> str:
84
+ # Validate percent escapes first
85
+ i = 0
86
+ while i < len(value):
87
+ if value[i] == "%":
88
+ if i + 2 >= len(value):
89
+ raise ValueError("Malformed percent escape in query string.")
90
+ if not all(c in "0123456789abcdefABCDEF" for c in value[i + 1 : i + 3]):
91
+ raise ValueError("Malformed percent escape in query string.")
92
+ i += 3
93
+ continue
94
+ i += 1
95
+ from urllib.parse import unquote
96
+
97
+ return unquote(value.replace("+", "%20"))
98
+
99
+
100
+ def _encode_rfc3986_upper(value: str) -> str:
101
+ encoded = quote(value, safe="-._~")
102
+ out: list[str] = []
103
+ i = 0
104
+ while i < len(encoded):
105
+ if encoded[i] == "%" and i + 2 < len(encoded):
106
+ out.append("%" + encoded[i + 1 : i + 3].upper())
107
+ i += 3
108
+ else:
109
+ out.append(encoded[i])
110
+ i += 1
111
+ return "".join(out)
112
+
113
+
114
+ def canonicalize_fulfillment_query(raw_query: Optional[str]) -> str:
115
+ source = (raw_query or "").strip()
116
+ if source.startswith("?"):
117
+ source = source[1:]
118
+ if not source:
119
+ return ""
120
+
121
+ pairs: list[tuple[str, str]] = []
122
+ seen_keys: set[str] = set()
123
+ for part in [segment for segment in source.split("&") if segment]:
124
+ if "=" in part:
125
+ raw_key, raw_val = part.split("=", 1)
126
+ else:
127
+ raw_key, raw_val = part, ""
128
+ key = _decode_form_component(raw_key)
129
+ val = _decode_form_component(raw_val)
130
+ if not key:
131
+ raise ValueError("Empty query key is not supported in Phase C MVP.")
132
+ if key in seen_keys:
133
+ raise ValueError(f"Duplicate query key '{key}' is not supported in Phase C MVP.")
134
+ seen_keys.add(key)
135
+ pairs.append((key, val))
136
+
137
+ pairs.sort(key=lambda item: (item[0], item[1]))
138
+ return "&".join(f"{_encode_rfc3986_upper(k)}={_encode_rfc3986_upper(v)}" for k, v in pairs)
139
+
140
+
141
+ def hash_canonical_fulfillment_query(raw_query: Optional[str]) -> str:
142
+ canonical = canonicalize_fulfillment_query(raw_query)
143
+ return _sha256_hex_utf8(canonical) if canonical else FULFILLMENT_ZERO_HASH_32
144
+
145
+
146
+ def _b64url_encode_json(payload: dict[str, Any]) -> str:
147
+ data = json.dumps(payload, separators=(",", ":"), sort_keys=False).encode("utf-8")
148
+ return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
149
+
150
+
151
+ def _b64url_decode_json(payload: str) -> dict[str, Any]:
152
+ padded = payload + "=" * ((4 - (len(payload) % 4)) % 4)
153
+ decoded = base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")
154
+ return json.loads(decoded)
155
+
156
+
157
+ def _normalize_hex32(value: str) -> str:
158
+ normalized = value.strip().lower()
159
+ if len(normalized) != 66 or not normalized.startswith("0x"):
160
+ raise ValueError("Expected bytes32 hex string.")
161
+ int(normalized[2:], 16)
162
+ return normalized
163
+
164
+
165
+ def _normalize_signature(value: str) -> str:
166
+ normalized = value.strip().lower()
167
+ if not normalized.startswith("0x"):
168
+ raise ValueError("Expected 0x-prefixed signature hex.")
169
+ int(normalized[2:], 16)
170
+ return normalized
171
+
172
+
173
+ def _normalize_address(value: str) -> str:
174
+ normalized = value.strip().lower()
175
+ if len(normalized) != 42 or not normalized.startswith("0x"):
176
+ raise ValueError("Expected 20-byte address.")
177
+ int(normalized[2:], 16)
178
+ return normalized
179
+
180
+
181
+ def _typed_domain(chain_id: int) -> dict[str, Any]:
182
+ return {
183
+ "name": FULFILLMENT_DOMAIN_NAME,
184
+ "version": FULFILLMENT_DOMAIN_VERSION,
185
+ "chainId": chain_id,
186
+ "verifyingContract": FULFILLMENT_DOMAIN_VERIFYING_CONTRACT,
187
+ }
188
+
189
+
190
+ def _ticket_request_auth_typed_data(message: dict[str, Any], chain_id: int) -> dict[str, Any]:
191
+ return {
192
+ "types": {
193
+ "EIP712Domain": [
194
+ {"name": "name", "type": "string"},
195
+ {"name": "version", "type": "string"},
196
+ {"name": "chainId", "type": "uint256"},
197
+ {"name": "verifyingContract", "type": "address"},
198
+ ],
199
+ "FulfillmentTicketRequestAuth": [
200
+ {"name": "action", "type": "string"},
201
+ {"name": "serviceSlug", "type": "string"},
202
+ {"name": "method", "type": "string"},
203
+ {"name": "path", "type": "string"},
204
+ {"name": "queryHash", "type": "bytes32"},
205
+ {"name": "bodyHash", "type": "bytes32"},
206
+ {"name": "cost", "type": "uint256"},
207
+ {"name": "issuedAt", "type": "uint256"},
208
+ {"name": "nonce", "type": "string"},
209
+ ],
210
+ },
211
+ "domain": _typed_domain(chain_id),
212
+ "primaryType": "FulfillmentTicketRequestAuth",
213
+ "message": message,
214
+ }
215
+
216
+
217
+ def _delivery_proof_typed_data(message: dict[str, Any], chain_id: int) -> dict[str, Any]:
218
+ return {
219
+ "types": {
220
+ "EIP712Domain": [
221
+ {"name": "name", "type": "string"},
222
+ {"name": "version", "type": "string"},
223
+ {"name": "chainId", "type": "uint256"},
224
+ {"name": "verifyingContract", "type": "address"},
225
+ ],
226
+ "FulfillmentDeliveryProof": [
227
+ {"name": "ticketId", "type": "bytes32"},
228
+ {"name": "deliveryProofId", "type": "bytes32"},
229
+ {"name": "merchantSigner", "type": "address"},
230
+ {"name": "serviceSlug", "type": "string"},
231
+ {"name": "completedAt", "type": "uint256"},
232
+ {"name": "statusCode", "type": "uint256"},
233
+ {"name": "latencyMs", "type": "uint256"},
234
+ {"name": "responseHash", "type": "bytes32"},
235
+ ],
236
+ },
237
+ "domain": _typed_domain(chain_id),
238
+ "primaryType": "FulfillmentDeliveryProof",
239
+ "message": message,
240
+ }
241
+
242
+
243
+ def _ticket_typed_data(message: dict[str, Any], chain_id: int) -> dict[str, Any]:
244
+ return {
245
+ "types": {
246
+ "EIP712Domain": [
247
+ {"name": "name", "type": "string"},
248
+ {"name": "version", "type": "string"},
249
+ {"name": "chainId", "type": "uint256"},
250
+ {"name": "verifyingContract", "type": "address"},
251
+ ],
252
+ "FulfillmentTicket": [
253
+ {"name": "ticketId", "type": "bytes32"},
254
+ {"name": "consumer", "type": "address"},
255
+ {"name": "merchantOwner", "type": "address"},
256
+ {"name": "gatewayConfigIdHash", "type": "bytes32"},
257
+ {"name": "serviceSlug", "type": "string"},
258
+ {"name": "method", "type": "string"},
259
+ {"name": "path", "type": "string"},
260
+ {"name": "queryHash", "type": "bytes32"},
261
+ {"name": "bodyHash", "type": "bytes32"},
262
+ {"name": "cost", "type": "uint256"},
263
+ {"name": "issuedAt", "type": "uint256"},
264
+ {"name": "expiresAt", "type": "uint256"},
265
+ ],
266
+ },
267
+ "domain": _typed_domain(chain_id),
268
+ "primaryType": "FulfillmentTicket",
269
+ "message": message,
270
+ }
271
+
272
+
273
+ def build_fulfillment_ticket_headers(*, ticket_id: str, ticket: Mapping[str, Any], client_request_id: Optional[str] = None) -> dict[str, str]:
274
+ headers = {
275
+ HEADER_TICKET_VERSION: str(ticket.get("version", "")),
276
+ HEADER_TICKET_PAYLOAD: str(ticket.get("payload", "")),
277
+ HEADER_TICKET_SIGNATURE: _normalize_signature(str(ticket.get("signature", ""))),
278
+ HEADER_TICKET_ID: _normalize_hex32(ticket_id),
279
+ }
280
+ if client_request_id:
281
+ headers[HEADER_CLIENT_REQUEST_ID] = client_request_id.strip()
282
+ return headers
283
+
284
+
285
+ def parse_fulfillment_ticket_headers(headers: Mapping[str, Any]) -> Optional[dict[str, Any]]:
286
+ lower = {str(k).lower(): v for k, v in headers.items()}
287
+ version = str(lower.get(HEADER_TICKET_VERSION, "")).strip()
288
+ payload = str(lower.get(HEADER_TICKET_PAYLOAD, "")).strip()
289
+ signature = str(lower.get(HEADER_TICKET_SIGNATURE, "")).strip()
290
+ ticket_id = str(lower.get(HEADER_TICKET_ID, "")).strip()
291
+ if version != str(FULFILLMENT_API_VERSION) or not payload or not signature or not ticket_id:
292
+ return None
293
+ try:
294
+ parsed = {
295
+ "ticketId": _normalize_hex32(ticket_id),
296
+ "ticket": {
297
+ "version": FULFILLMENT_API_VERSION,
298
+ "payload": payload,
299
+ "signature": _normalize_signature(signature),
300
+ },
301
+ "clientRequestId": str(lower.get(HEADER_CLIENT_REQUEST_ID, "")).strip() or None,
302
+ }
303
+ return parsed
304
+ except Exception:
305
+ return None
306
+
307
+
308
+ @dataclass
309
+ class GhostFulfillmentConsumer:
310
+ private_key: str
311
+ base_url: str = os.getenv("GHOST_GATE_BASE_URL", "https://ghostprotocol.cc")
312
+ chain_id: int = FULFILLMENT_DEFAULT_CHAIN_ID
313
+ default_service_slug: str = "agent-18755"
314
+
315
+ def __post_init__(self) -> None:
316
+ self.private_key = self.private_key.strip()
317
+ self.base_url = _normalize_base_url(self.base_url)
318
+ if not self.private_key.startswith("0x") or len(self.private_key) != 66:
319
+ raise ValueError("private_key must be a 0x-prefixed 32-byte hex key")
320
+
321
+ def _sign_typed(self, typed_data: dict[str, Any]) -> str:
322
+ signable = encode_typed_data(full_message=typed_data)
323
+ return Account.sign_message(signable, private_key=self.private_key).signature.hex().lower()
324
+
325
+ def request_ticket(
326
+ self,
327
+ *,
328
+ path: str,
329
+ service_slug: Optional[str] = None,
330
+ method: str = "POST",
331
+ query: Optional[str] = None,
332
+ body_json: Optional[Any] = None,
333
+ cost: int = 1,
334
+ client_request_id: Optional[str] = None,
335
+ timeout: int = 15,
336
+ ) -> requests.Response:
337
+ if cost <= 0:
338
+ raise ValueError("cost must be > 0")
339
+ service = (service_slug or self.default_service_slug).strip()
340
+ method_norm = method.strip().upper()
341
+ path_norm = path.strip()
342
+ if not path_norm.startswith("/"):
343
+ raise ValueError("path must start with '/'")
344
+ query_str = (query or "").strip()
345
+ issued_at = int(time.time())
346
+ nonce = f"fx-{uuid.uuid4().hex}"
347
+
348
+ auth_message = {
349
+ "action": "fulfillment_ticket",
350
+ "serviceSlug": service,
351
+ "method": method_norm,
352
+ "path": path_norm,
353
+ "queryHash": hash_canonical_fulfillment_query(query_str),
354
+ "bodyHash": hash_canonical_fulfillment_body_json(body_json if body_json is not None else {}),
355
+ "cost": str(int(cost)),
356
+ "issuedAt": str(issued_at),
357
+ "nonce": nonce,
358
+ }
359
+ auth_signature = self._sign_typed(_ticket_request_auth_typed_data(auth_message, self.chain_id))
360
+
361
+ payload = {
362
+ "serviceSlug": service,
363
+ "method": method_norm,
364
+ "path": path_norm,
365
+ "cost": int(cost),
366
+ "query": query_str,
367
+ "clientRequestId": client_request_id or f"fx-{int(time.time() * 1000)}",
368
+ "ticketRequestAuth": {
369
+ "payload": _b64url_encode_json(auth_message),
370
+ "signature": auth_signature,
371
+ },
372
+ }
373
+ return requests.post(
374
+ f"{self.base_url}/api/fulfillment/ticket",
375
+ json=payload,
376
+ headers={"accept": "application/json, text/plain;q=0.9, */*;q=0.8"},
377
+ timeout=timeout,
378
+ )
379
+
380
+ def execute(
381
+ self,
382
+ *,
383
+ path: str,
384
+ service_slug: Optional[str] = None,
385
+ method: str = "POST",
386
+ query: Optional[str] = None,
387
+ body_json: Optional[Any] = None,
388
+ cost: int = 1,
389
+ timeout: int = 20,
390
+ ) -> dict[str, Any]:
391
+ ticket_res = self.request_ticket(
392
+ path=path,
393
+ service_slug=service_slug,
394
+ method=method,
395
+ query=query,
396
+ body_json=body_json,
397
+ cost=cost,
398
+ )
399
+ ticket_payload: Any
400
+ try:
401
+ ticket_payload = ticket_res.json()
402
+ except Exception:
403
+ ticket_payload = None
404
+ if ticket_res.status_code < 200 or ticket_res.status_code >= 300:
405
+ return {
406
+ "ticket": {"status": ticket_res.status_code, "payload": ticket_payload},
407
+ "merchant": {"attempted": False},
408
+ }
409
+
410
+ if not isinstance(ticket_payload, dict):
411
+ raise RuntimeError("Fulfillment ticket response was not JSON object")
412
+ ticket = ticket_payload.get("ticket")
413
+ ticket_id = str(ticket_payload.get("ticketId", ""))
414
+ merchant_target = ticket_payload.get("merchantTarget") or {}
415
+ endpoint_url = str((merchant_target or {}).get("endpointUrl", "")).rstrip("/")
416
+ target_path = str((merchant_target or {}).get("path", path)).strip()
417
+ if not endpoint_url:
418
+ raise RuntimeError("Fulfillment ticket response missing merchantTarget.endpointUrl")
419
+
420
+ full_url = endpoint_url + target_path
421
+ if query:
422
+ qs = query[1:] if str(query).startswith("?") else str(query)
423
+ full_url = f"{full_url}?{qs}"
424
+
425
+ headers = build_fulfillment_ticket_headers(ticket_id=ticket_id, ticket=ticket)
426
+ headers.setdefault("accept", "application/json, text/plain;q=0.9, */*;q=0.8")
427
+ if body_json is not None:
428
+ headers.setdefault("content-type", "application/json")
429
+
430
+ merchant_res = requests.request(
431
+ method=method.strip().upper(),
432
+ url=full_url,
433
+ headers=headers,
434
+ json=body_json if body_json is not None else None,
435
+ timeout=timeout,
436
+ )
437
+ try:
438
+ merchant_json = merchant_res.json()
439
+ except Exception:
440
+ merchant_json = None
441
+ return {
442
+ "ticket": {"status": ticket_res.status_code, "payload": ticket_payload},
443
+ "merchant": {
444
+ "attempted": True,
445
+ "status": merchant_res.status_code,
446
+ "url": full_url,
447
+ "body": merchant_json if merchant_json is not None else merchant_res.text,
448
+ },
449
+ }
450
+
451
+
452
+ @dataclass
453
+ class GhostFulfillmentMerchant:
454
+ protocol_signer_addresses: Optional[list[str]] = None
455
+ delegated_private_key: Optional[str] = None
456
+ base_url: str = os.getenv("GHOST_GATE_BASE_URL", "https://ghostprotocol.cc")
457
+ chain_id: int = FULFILLMENT_DEFAULT_CHAIN_ID
458
+
459
+ def __post_init__(self) -> None:
460
+ self.base_url = _normalize_base_url(self.base_url)
461
+ signer_addresses = self.protocol_signer_addresses or list(DEFAULT_FULFILLMENT_PROTOCOL_SIGNER_ADDRESSES)
462
+ self.protocol_signer_addresses = [_normalize_address(addr) for addr in signer_addresses]
463
+ if not self.protocol_signer_addresses:
464
+ raise ValueError("At least one protocol signer address is required")
465
+ if self.delegated_private_key:
466
+ self.delegated_private_key = self.delegated_private_key.strip()
467
+
468
+ def require_fulfillment_ticket(
469
+ self,
470
+ *,
471
+ headers: Mapping[str, Any],
472
+ method: Optional[str] = None,
473
+ path: Optional[str] = None,
474
+ query: Optional[str] = None,
475
+ body_json: Any = None,
476
+ expected_service_slug: Optional[str] = None,
477
+ now_ms: Optional[int] = None,
478
+ ) -> dict[str, Any]:
479
+ parsed = parse_fulfillment_ticket_headers(headers)
480
+ if not parsed:
481
+ raise ValueError("Missing or invalid fulfillment ticket headers")
482
+
483
+ payload = _b64url_decode_json(parsed["ticket"]["payload"])
484
+ if _normalize_hex32(str(payload.get("ticketId", ""))) != parsed["ticketId"]:
485
+ raise ValueError("ticketId header mismatch")
486
+
487
+ signable = encode_typed_data(full_message=_ticket_typed_data(payload, self.chain_id))
488
+ recovered = _normalize_address(Account.recover_message(signable, signature=parsed["ticket"]["signature"]))
489
+ if recovered not in self.protocol_signer_addresses:
490
+ raise ValueError("Ticket signer is not in allowed protocol signer set")
491
+
492
+ now_seconds = int((now_ms if now_ms is not None else int(time.time() * 1000)) / 1000)
493
+ if int(str(payload["expiresAt"])) < now_seconds:
494
+ raise ValueError("Fulfillment ticket expired")
495
+
496
+ if expected_service_slug and str(payload.get("serviceSlug", "")).strip() != expected_service_slug.strip():
497
+ raise ValueError("serviceSlug mismatch")
498
+ if method and str(payload.get("method", "")).strip().upper() != method.strip().upper():
499
+ raise ValueError("method mismatch")
500
+ if path and str(payload.get("path", "")).strip() != path.strip():
501
+ raise ValueError("path mismatch")
502
+ if query is not None and str(payload.get("queryHash", "")).strip().lower() != hash_canonical_fulfillment_query(query):
503
+ raise ValueError("queryHash mismatch")
504
+ if body_json is not None and str(payload.get("bodyHash", "")).strip().lower() != hash_canonical_fulfillment_body_json(body_json):
505
+ raise ValueError("bodyHash mismatch")
506
+
507
+ return {
508
+ "ticketId": parsed["ticketId"],
509
+ "ticket": parsed["ticket"],
510
+ "payload": payload,
511
+ "signer": recovered,
512
+ "clientRequestId": parsed.get("clientRequestId"),
513
+ }
514
+
515
+ def _sign_delivery_proof(self, message: dict[str, Any]) -> str:
516
+ if not self.delegated_private_key:
517
+ raise ValueError("delegated_private_key is required for capture_completion")
518
+ signable = encode_typed_data(full_message=_delivery_proof_typed_data(message, self.chain_id))
519
+ return Account.sign_message(signable, private_key=self.delegated_private_key).signature.hex().lower()
520
+
521
+ def capture_completion(
522
+ self,
523
+ *,
524
+ ticket_id: str,
525
+ service_slug: str,
526
+ status_code: int,
527
+ latency_ms: int,
528
+ response_body_json: Any = None,
529
+ response_body_text: Optional[str] = None,
530
+ completed_at: Optional[int] = None,
531
+ timeout: int = 15,
532
+ ) -> requests.Response:
533
+ if not self.delegated_private_key:
534
+ raise ValueError("delegated_private_key is required")
535
+ if status_code < 100 or status_code > 599:
536
+ raise ValueError("status_code out of range")
537
+ if latency_ms < 0:
538
+ raise ValueError("latency_ms must be >= 0")
539
+
540
+ merchant_signer = _normalize_address(Account.from_key(self.delegated_private_key).address)
541
+ if response_body_json is not None:
542
+ response_hash = hash_canonical_fulfillment_body_json(response_body_json)
543
+ elif response_body_text is not None:
544
+ response_hash = _sha256_hex_utf8(response_body_text)
545
+ else:
546
+ response_hash = FULFILLMENT_ZERO_HASH_32
547
+
548
+ proof_message = {
549
+ "ticketId": _normalize_hex32(ticket_id),
550
+ "deliveryProofId": "0x" + os.urandom(32).hex(),
551
+ "merchantSigner": merchant_signer,
552
+ "serviceSlug": service_slug.strip(),
553
+ "completedAt": str(int(completed_at if completed_at is not None else time.time())),
554
+ "statusCode": str(int(status_code)),
555
+ "latencyMs": str(int(latency_ms)),
556
+ "responseHash": response_hash,
557
+ }
558
+ proof_signature = self._sign_delivery_proof(proof_message)
559
+ delivery_proof = {"payload": _b64url_encode_json(proof_message), "signature": proof_signature}
560
+ body = {
561
+ "ticketId": proof_message["ticketId"],
562
+ "deliveryProof": delivery_proof,
563
+ "completionMeta": {
564
+ "statusCode": int(status_code),
565
+ "latencyMs": int(latency_ms),
566
+ **({"responseHash": response_hash} if response_hash != FULFILLMENT_ZERO_HASH_32 else {}),
567
+ },
568
+ }
569
+ return requests.post(
570
+ f"{self.base_url}/api/fulfillment/capture",
571
+ json=body,
572
+ headers={"accept": "application/json, text/plain;q=0.9, */*;q=0.8"},
573
+ timeout=timeout,
574
+ )
@@ -0,0 +1,446 @@
1
+ """GhostGate Python SDK.
2
+
3
+ Drop this file into your project and import `GhostGate` to protect routes
4
+ using credit checks.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from functools import wraps
15
+ from typing import Any, Callable, Optional
16
+ from urllib.parse import quote
17
+
18
+ import requests
19
+ from eth_account import Account
20
+ from eth_account.messages import encode_typed_data
21
+
22
+
23
+ ConnectResult = dict[str, Any]
24
+ TelemetryResult = dict[str, Any]
25
+
26
+
27
+ class HeartbeatController:
28
+ """Controls a best-effort heartbeat loop started by `start_heartbeat`."""
29
+
30
+ def __init__(self, stop_callback: Callable[[], None]) -> None:
31
+ self._stop_callback = stop_callback
32
+
33
+ def stop(self) -> None:
34
+ self._stop_callback()
35
+
36
+
37
+ class GhostGate:
38
+ """Credit-gate helper for Python APIs."""
39
+
40
+ DEFAULT_BASE_URL = "https://ghostprotocol.cc"
41
+ DEFAULT_SERVICE_SLUG = "connect"
42
+ DEFAULT_CREDIT_COST = 1
43
+ DEFAULT_TIMEOUT_SECONDS = 10.0
44
+ DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 60.0
45
+ DOMAIN_NAME = "GhostGate"
46
+ DOMAIN_VERSION = "1"
47
+
48
+ def __init__(
49
+ self,
50
+ api_key: Optional[str] = None,
51
+ *,
52
+ private_key: Optional[str] = None,
53
+ chain_id: int = 8453,
54
+ base_url: str = DEFAULT_BASE_URL,
55
+ service_slug: str = DEFAULT_SERVICE_SLUG,
56
+ credit_cost: int = DEFAULT_CREDIT_COST,
57
+ timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
58
+ ) -> None:
59
+ self.api_key = self._normalize_optional_string(api_key) or self._normalize_optional_string(os.getenv("GHOST_API_KEY"))
60
+ self.chain_id = chain_id
61
+ env_base_url = os.getenv("GHOST_GATE_BASE_URL", "").strip()
62
+ candidate_base_url = env_base_url or base_url
63
+ self.base_url = candidate_base_url.rstrip("/")
64
+ self.gate_url = f"{self.base_url}/api/gate"
65
+ self.pulse_url = f"{self.base_url}/api/telemetry/pulse"
66
+ self.outcome_url = f"{self.base_url}/api/telemetry/outcome"
67
+ self.service_slug = self._normalize_optional_string(service_slug) or self.DEFAULT_SERVICE_SLUG
68
+ self.credit_cost = self._normalize_credit_cost(credit_cost)
69
+ self.timeout_seconds = self._normalize_timeout(timeout_seconds)
70
+ self.private_key = private_key or os.getenv("GHOST_SIGNER_PRIVATE_KEY") or os.getenv("PRIVATE_KEY")
71
+ if not self.private_key:
72
+ raise ValueError("A signing private key is required (private_key arg or GHOST_SIGNER_PRIVATE_KEY/PRIVATE_KEY).")
73
+
74
+ @property
75
+ def is_connected(self) -> bool:
76
+ return self.api_key is not None
77
+
78
+ @property
79
+ def endpoint(self) -> str:
80
+ return self.gate_url
81
+
82
+ def connect(
83
+ self,
84
+ api_key: Optional[str] = None,
85
+ *,
86
+ service: Optional[str] = None,
87
+ cost: Optional[int] = None,
88
+ method: str = "POST",
89
+ timeout_seconds: Optional[float] = None,
90
+ ) -> ConnectResult:
91
+ """Signs and sends an EIP-712 gate request to `/api/gate/<service>`."""
92
+ resolved_api_key = self._normalize_optional_string(api_key) or self.api_key
93
+ if not resolved_api_key:
94
+ raise ValueError("connect(api_key?) requires a non-empty API key via argument or constructor.")
95
+
96
+ resolved_service = self._normalize_optional_string(service) or self.service_slug
97
+ resolved_cost = self._normalize_credit_cost(cost if cost is not None else self.credit_cost)
98
+ resolved_method = (method or "POST").strip().upper() or "POST"
99
+ endpoint = f"{self.gate_url}/{quote(resolved_service, safe='')}"
100
+
101
+ try:
102
+ response = self._request_access(
103
+ service=resolved_service,
104
+ cost=resolved_cost,
105
+ method=resolved_method,
106
+ timeout_seconds=timeout_seconds,
107
+ )
108
+ payload = self._parse_response_payload(response)
109
+ if response.ok:
110
+ self.api_key = resolved_api_key
111
+ return {
112
+ "connected": response.ok,
113
+ "apiKeyPrefix": self._api_key_prefix(resolved_api_key),
114
+ "endpoint": endpoint,
115
+ "status": response.status_code,
116
+ "payload": payload,
117
+ }
118
+ except requests.RequestException as error:
119
+ return {
120
+ "connected": False,
121
+ "apiKeyPrefix": self._api_key_prefix(resolved_api_key),
122
+ "endpoint": endpoint,
123
+ "status": 0,
124
+ "payload": {"error": str(error)},
125
+ }
126
+
127
+ def pulse(
128
+ self,
129
+ *,
130
+ api_key: Optional[str] = None,
131
+ agent_id: Optional[str] = None,
132
+ service_slug: Optional[str] = None,
133
+ metadata: Optional[dict[str, Any]] = None,
134
+ timeout_seconds: Optional[float] = None,
135
+ ) -> TelemetryResult:
136
+ """Sends heartbeat telemetry to `/api/telemetry/pulse`."""
137
+ resolved_api_key = self._normalize_optional_string(api_key) or self.api_key
138
+ resolved_agent_id = self._normalize_optional_string(agent_id)
139
+ resolved_service_slug = self._normalize_optional_string(service_slug) or self.service_slug
140
+
141
+ self._assert_telemetry_identity(
142
+ api_key=resolved_api_key,
143
+ agent_id=resolved_agent_id,
144
+ service_slug=resolved_service_slug,
145
+ )
146
+
147
+ payload: dict[str, Any] = {}
148
+ if resolved_api_key:
149
+ payload["apiKey"] = resolved_api_key
150
+ if resolved_agent_id:
151
+ payload["agentId"] = resolved_agent_id
152
+ if resolved_service_slug:
153
+ payload["serviceSlug"] = resolved_service_slug
154
+ if metadata:
155
+ payload["metadata"] = metadata
156
+
157
+ return self._post_telemetry(
158
+ endpoint=self.pulse_url,
159
+ payload=payload,
160
+ timeout_seconds=timeout_seconds,
161
+ )
162
+
163
+ def outcome(
164
+ self,
165
+ *,
166
+ success: bool,
167
+ status_code: Optional[int] = None,
168
+ api_key: Optional[str] = None,
169
+ agent_id: Optional[str] = None,
170
+ service_slug: Optional[str] = None,
171
+ metadata: Optional[dict[str, Any]] = None,
172
+ timeout_seconds: Optional[float] = None,
173
+ ) -> TelemetryResult:
174
+ """Sends consumer outcome telemetry to `/api/telemetry/outcome`."""
175
+ resolved_api_key = self._normalize_optional_string(api_key) or self.api_key
176
+ resolved_agent_id = self._normalize_optional_string(agent_id)
177
+ resolved_service_slug = self._normalize_optional_string(service_slug) or self.service_slug
178
+
179
+ self._assert_telemetry_identity(
180
+ api_key=resolved_api_key,
181
+ agent_id=resolved_agent_id,
182
+ service_slug=resolved_service_slug,
183
+ )
184
+
185
+ payload: dict[str, Any] = {
186
+ "success": bool(success),
187
+ }
188
+ normalized_status_code = self._normalize_status_code(status_code)
189
+ if normalized_status_code is not None:
190
+ payload["statusCode"] = normalized_status_code
191
+ if resolved_api_key:
192
+ payload["apiKey"] = resolved_api_key
193
+ if resolved_agent_id:
194
+ payload["agentId"] = resolved_agent_id
195
+ if resolved_service_slug:
196
+ payload["serviceSlug"] = resolved_service_slug
197
+ if metadata:
198
+ payload["metadata"] = metadata
199
+
200
+ return self._post_telemetry(
201
+ endpoint=self.outcome_url,
202
+ payload=payload,
203
+ timeout_seconds=timeout_seconds,
204
+ )
205
+
206
+ def start_heartbeat(
207
+ self,
208
+ *,
209
+ interval_seconds: float = DEFAULT_HEARTBEAT_INTERVAL_SECONDS,
210
+ immediate: bool = True,
211
+ api_key: Optional[str] = None,
212
+ agent_id: Optional[str] = None,
213
+ service_slug: Optional[str] = None,
214
+ metadata: Optional[dict[str, Any]] = None,
215
+ on_result: Optional[Callable[[TelemetryResult], None]] = None,
216
+ on_error: Optional[Callable[[Exception], None]] = None,
217
+ timeout_seconds: Optional[float] = None,
218
+ ) -> HeartbeatController:
219
+ """Starts a background heartbeat loop. Returns a controller with `stop()`."""
220
+ if interval_seconds <= 0:
221
+ raise ValueError("interval_seconds must be > 0.")
222
+
223
+ stop_event = threading.Event()
224
+
225
+ def tick() -> None:
226
+ try:
227
+ result = self.pulse(
228
+ api_key=api_key,
229
+ agent_id=agent_id,
230
+ service_slug=service_slug,
231
+ metadata=metadata,
232
+ timeout_seconds=timeout_seconds,
233
+ )
234
+ if on_result:
235
+ on_result(result)
236
+ except Exception as error: # noqa: BLE001 - callback boundary
237
+ if on_error:
238
+ on_error(error)
239
+
240
+ def run_loop() -> None:
241
+ if immediate:
242
+ tick()
243
+ while not stop_event.wait(interval_seconds):
244
+ tick()
245
+
246
+ worker = threading.Thread(target=run_loop, daemon=True, name="ghostgate-heartbeat")
247
+ worker.start()
248
+
249
+ def stop() -> None:
250
+ stop_event.set()
251
+ if worker.is_alive():
252
+ worker.join(timeout=1)
253
+
254
+ return HeartbeatController(stop)
255
+
256
+ def guard(
257
+ self,
258
+ cost: int,
259
+ *,
260
+ service: Optional[str] = None,
261
+ method: str = "GET",
262
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
263
+ """Decorator that verifies paid access via the GhostGate gateway."""
264
+ if cost <= 0:
265
+ raise ValueError("cost must be greater than 0")
266
+ resolved_service = self._normalize_optional_string(service) or self.service_slug
267
+
268
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
269
+ @wraps(func)
270
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
271
+ if not self._verify_access(service=resolved_service, cost=cost, method=method):
272
+ return "Payment Required"
273
+
274
+ result = func(*args, **kwargs)
275
+ status_code = self._extract_status_code(result)
276
+ success = status_code is None or status_code < 500
277
+ self.outcome(success=success, status_code=status_code, service_slug=resolved_service)
278
+ return result
279
+
280
+ return wrapper
281
+
282
+ return decorator
283
+
284
+ def _build_access_payload(self, service: str) -> dict[str, Any]:
285
+ return {
286
+ "service": service,
287
+ "timestamp": int(time.time()),
288
+ "nonce": uuid.uuid4().hex,
289
+ }
290
+
291
+ def _sign_access_payload(self, payload: dict[str, Any]) -> str:
292
+ typed_data = {
293
+ "types": {
294
+ "EIP712Domain": [
295
+ {"name": "name", "type": "string"},
296
+ {"name": "version", "type": "string"},
297
+ {"name": "chainId", "type": "uint256"},
298
+ ],
299
+ "Access": [
300
+ {"name": "service", "type": "string"},
301
+ {"name": "timestamp", "type": "uint256"},
302
+ {"name": "nonce", "type": "string"},
303
+ ],
304
+ },
305
+ "domain": {
306
+ "name": self.DOMAIN_NAME,
307
+ "version": self.DOMAIN_VERSION,
308
+ "chainId": self.chain_id,
309
+ },
310
+ "primaryType": "Access",
311
+ "message": payload,
312
+ }
313
+ signable = encode_typed_data(full_message=typed_data)
314
+ signed = Account.sign_message(signable, private_key=self.private_key)
315
+ return signed.signature.hex()
316
+
317
+ def _request_access(
318
+ self,
319
+ *,
320
+ service: str,
321
+ cost: int,
322
+ method: str,
323
+ timeout_seconds: Optional[float] = None,
324
+ ) -> requests.Response:
325
+ payload = self._build_access_payload(service)
326
+ signature = self._sign_access_payload(payload)
327
+ headers = {
328
+ "x-ghost-sig": signature,
329
+ "x-ghost-payload": json.dumps(payload),
330
+ "x-ghost-credit-cost": str(cost),
331
+ "accept": "application/json, text/plain;q=0.9, */*;q=0.8",
332
+ }
333
+ target = f"{self.gate_url}/{quote(service, safe='')}"
334
+ return requests.request(
335
+ method=method.upper(),
336
+ url=target,
337
+ headers=headers,
338
+ timeout=self._resolve_timeout(timeout_seconds),
339
+ )
340
+
341
+ def _verify_access(self, *, service: str, cost: int, method: str) -> bool:
342
+ result = self.connect(service=service, cost=cost, method=method)
343
+ return bool(result.get("connected"))
344
+
345
+ def send_pulse(self, agent_id: Optional[str] = None) -> bool:
346
+ """Legacy alias for `pulse(...).ok`."""
347
+ return bool(self.pulse(agent_id=agent_id).get("ok"))
348
+
349
+ def report_consumer_outcome(
350
+ self,
351
+ *,
352
+ success: bool,
353
+ status_code: Optional[int] = None,
354
+ agent_id: Optional[str] = None,
355
+ ) -> bool:
356
+ """Legacy alias for `outcome(...).ok`."""
357
+ return bool(self.outcome(success=success, status_code=status_code, agent_id=agent_id).get("ok"))
358
+
359
+ @staticmethod
360
+ def _extract_status_code(result: Any) -> Optional[int]:
361
+ if hasattr(result, "status_code"):
362
+ maybe_status = getattr(result, "status_code")
363
+ if isinstance(maybe_status, int):
364
+ return maybe_status
365
+ if isinstance(result, tuple) and len(result) >= 2 and isinstance(result[1], int):
366
+ return result[1]
367
+ return None
368
+
369
+ @staticmethod
370
+ def _normalize_optional_string(value: Optional[str]) -> Optional[str]:
371
+ if value is None:
372
+ return None
373
+ trimmed = value.strip()
374
+ return trimmed if trimmed else None
375
+
376
+ @staticmethod
377
+ def _normalize_credit_cost(value: int) -> int:
378
+ if not isinstance(value, int) or value <= 0:
379
+ raise ValueError("credit_cost must be an integer greater than 0.")
380
+ return value
381
+
382
+ @staticmethod
383
+ def _normalize_timeout(value: float) -> float:
384
+ if value <= 0:
385
+ raise ValueError("timeout_seconds must be > 0.")
386
+ return value
387
+
388
+ @staticmethod
389
+ def _normalize_status_code(value: Optional[int]) -> Optional[int]:
390
+ if value is None:
391
+ return None
392
+ if not isinstance(value, int) or value < 100 or value > 599:
393
+ raise ValueError("status_code must be an integer in the HTTP status range (100-599).")
394
+ return value
395
+
396
+ @staticmethod
397
+ def _api_key_prefix(api_key: str) -> str:
398
+ return api_key if len(api_key) <= 8 else f"{api_key[:8]}..."
399
+
400
+ @staticmethod
401
+ def _parse_response_payload(response: requests.Response) -> Any:
402
+ try:
403
+ return response.json()
404
+ except ValueError:
405
+ return response.text
406
+
407
+ @staticmethod
408
+ def _assert_telemetry_identity(
409
+ *,
410
+ api_key: Optional[str],
411
+ agent_id: Optional[str],
412
+ service_slug: Optional[str],
413
+ ) -> None:
414
+ if api_key or agent_id or service_slug:
415
+ return
416
+ raise ValueError("Telemetry calls require at least one of api_key, agent_id, or service_slug.")
417
+
418
+ def _resolve_timeout(self, timeout_seconds: Optional[float]) -> float:
419
+ return self.timeout_seconds if timeout_seconds is None else self._normalize_timeout(timeout_seconds)
420
+
421
+ def _post_telemetry(
422
+ self,
423
+ *,
424
+ endpoint: str,
425
+ payload: dict[str, Any],
426
+ timeout_seconds: Optional[float],
427
+ ) -> TelemetryResult:
428
+ try:
429
+ response = requests.post(
430
+ endpoint,
431
+ json=payload,
432
+ timeout=self._resolve_timeout(timeout_seconds),
433
+ )
434
+ return {
435
+ "ok": response.ok,
436
+ "endpoint": endpoint,
437
+ "status": response.status_code,
438
+ "payload": self._parse_response_payload(response),
439
+ }
440
+ except requests.RequestException as error:
441
+ return {
442
+ "ok": False,
443
+ "endpoint": endpoint,
444
+ "status": 0,
445
+ "payload": {"error": str(error)},
446
+ }
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: ghostgate-sdk
3
+ Version: 0.1.0
4
+ Summary: Ghost Protocol Python SDK for gate access and telemetry.
5
+ Author: Ghost Protocol
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://ghostprotocol.cc
8
+ Project-URL: Repository, https://github.com/Ghost-Protocol-Infrastructure/GHOST_PROTOCOL
9
+ Project-URL: Documentation, https://ghostprotocol.cc/docs
10
+ Project-URL: Issues, https://github.com/Ghost-Protocol-Infrastructure/GHOST_PROTOCOL/issues
11
+ Keywords: ghostgate,ghost protocol,web3,api,sdk,telemetry
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: requests>=2.31.0
23
+ Requires-Dist: eth-account>=0.13.0
24
+ Dynamic: license-file
25
+
26
+ # GhostGate Python SDK
27
+
28
+ Python SDK for Ghost Protocol gate access and telemetry.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install ghostgate-sdk
34
+ ```
35
+
36
+ ## Quickstart
37
+
38
+ ```python
39
+ import os
40
+ from ghostgate import GhostGate
41
+
42
+ sdk = GhostGate(
43
+ api_key=os.environ["GHOST_API_KEY"],
44
+ private_key=os.environ["GHOST_SIGNER_PRIVATE_KEY"],
45
+ base_url=os.getenv("GHOST_GATE_BASE_URL", "https://ghostprotocol.cc"),
46
+ chain_id=8453,
47
+ service_slug="agent-18755",
48
+ credit_cost=1,
49
+ )
50
+
51
+ result = sdk.connect()
52
+ print(result)
53
+ ```
54
+
55
+ ## Canonical methods
56
+
57
+ - `connect(...)`
58
+ - `pulse(...)`
59
+ - `outcome(...)`
60
+ - `start_heartbeat(...)`
61
+
62
+ Backward-compatible aliases are also available:
63
+
64
+ - `send_pulse(...)`
65
+ - `report_consumer_outcome(...)`
66
+
67
+ ## Security note
68
+
69
+ Use signer private keys only in trusted backend/server/CLI environments. Never expose private keys in frontend code.
70
+
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ ghost_fulfillment.py
4
+ ghostgate.py
5
+ pyproject.toml
6
+ ghostgate_sdk.egg-info/PKG-INFO
7
+ ghostgate_sdk.egg-info/SOURCES.txt
8
+ ghostgate_sdk.egg-info/dependency_links.txt
9
+ ghostgate_sdk.egg-info/requires.txt
10
+ ghostgate_sdk.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ requests>=2.31.0
2
+ eth-account>=0.13.0
@@ -0,0 +1,2 @@
1
+ ghost_fulfillment
2
+ ghostgate
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ghostgate-sdk"
7
+ version = "0.1.0"
8
+ description = "Ghost Protocol Python SDK for gate access and telemetry."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Ghost Protocol" }
15
+ ]
16
+ keywords = ["ghostgate", "ghost protocol", "web3", "api", "sdk", "telemetry"]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Developers",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ ]
26
+ dependencies = [
27
+ "requests>=2.31.0",
28
+ "eth-account>=0.13.0",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://ghostprotocol.cc"
33
+ Repository = "https://github.com/Ghost-Protocol-Infrastructure/GHOST_PROTOCOL"
34
+ Documentation = "https://ghostprotocol.cc/docs"
35
+ Issues = "https://github.com/Ghost-Protocol-Infrastructure/GHOST_PROTOCOL/issues"
36
+
37
+ [tool.setuptools]
38
+ py-modules = ["ghostgate", "ghost_fulfillment"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+