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.
- ghostgate_sdk-0.1.0/LICENSE +21 -0
- ghostgate_sdk-0.1.0/PKG-INFO +70 -0
- ghostgate_sdk-0.1.0/README.md +45 -0
- ghostgate_sdk-0.1.0/ghost_fulfillment.py +574 -0
- ghostgate_sdk-0.1.0/ghostgate.py +446 -0
- ghostgate_sdk-0.1.0/ghostgate_sdk.egg-info/PKG-INFO +70 -0
- ghostgate_sdk-0.1.0/ghostgate_sdk.egg-info/SOURCES.txt +10 -0
- ghostgate_sdk-0.1.0/ghostgate_sdk.egg-info/dependency_links.txt +1 -0
- ghostgate_sdk-0.1.0/ghostgate_sdk.egg-info/requires.txt +2 -0
- ghostgate_sdk-0.1.0/ghostgate_sdk.egg-info/top_level.txt +2 -0
- ghostgate_sdk-0.1.0/pyproject.toml +38 -0
- ghostgate_sdk-0.1.0/setup.cfg +4 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|