bankofbots 0.4.2__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.
- bankofbots-0.4.2/.gitignore +16 -0
- bankofbots-0.4.2/PKG-INFO +9 -0
- bankofbots-0.4.2/bob/__init__.py +38 -0
- bankofbots-0.4.2/bob/client.py +729 -0
- bankofbots-0.4.2/bob/exceptions.py +41 -0
- bankofbots-0.4.2/bob/types.py +191 -0
- bankofbots-0.4.2/pyproject.toml +16 -0
- bankofbots-0.4.2/tests/test_client.py +237 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bankofbots
|
|
3
|
+
Version: 0.4.2
|
|
4
|
+
Summary: Python SDK for Bank of Bots — economic infrastructure for autonomous agents
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Requires-Dist: httpx>=0.25
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
9
|
+
Requires-Dist: respx; extra == 'dev'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Bank of Bots Python SDK."""
|
|
2
|
+
|
|
3
|
+
from .client import BoBClient
|
|
4
|
+
from .exceptions import AuthenticationError, BoBError, ForbiddenError, NotFoundError
|
|
5
|
+
from .types import (
|
|
6
|
+
APIKeyRecord,
|
|
7
|
+
Agent,
|
|
8
|
+
AgentCreditResponse,
|
|
9
|
+
AgentNodeBinding,
|
|
10
|
+
CreditEvent,
|
|
11
|
+
HistoricalPaymentProofImport,
|
|
12
|
+
InboxEvent,
|
|
13
|
+
PaymentIntent,
|
|
14
|
+
PaymentIntentProof,
|
|
15
|
+
PaymentProofOwnershipChallenge,
|
|
16
|
+
ProofCreditOutcome,
|
|
17
|
+
WebhookSubscriber,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"BoBClient",
|
|
22
|
+
"APIKeyRecord",
|
|
23
|
+
"Agent",
|
|
24
|
+
"AgentCreditResponse",
|
|
25
|
+
"AgentNodeBinding",
|
|
26
|
+
"CreditEvent",
|
|
27
|
+
"HistoricalPaymentProofImport",
|
|
28
|
+
"InboxEvent",
|
|
29
|
+
"PaymentIntent",
|
|
30
|
+
"PaymentIntentProof",
|
|
31
|
+
"PaymentProofOwnershipChallenge",
|
|
32
|
+
"ProofCreditOutcome",
|
|
33
|
+
"WebhookSubscriber",
|
|
34
|
+
"BoBError",
|
|
35
|
+
"AuthenticationError",
|
|
36
|
+
"ForbiddenError",
|
|
37
|
+
"NotFoundError",
|
|
38
|
+
]
|
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
"""BoBClient — main entry point for the Bank of Bots Python SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .exceptions import AuthenticationError, BoBError, ForbiddenError, NotFoundError
|
|
10
|
+
from .types import (
|
|
11
|
+
APIKeyRecord,
|
|
12
|
+
Agent,
|
|
13
|
+
AgentCreditResponse,
|
|
14
|
+
AgentNodeBinding,
|
|
15
|
+
CreditEvent,
|
|
16
|
+
HistoricalPaymentProofImport,
|
|
17
|
+
InboxEvent,
|
|
18
|
+
PaymentIntent,
|
|
19
|
+
PaymentIntentProof,
|
|
20
|
+
PaymentProofOwnershipChallenge,
|
|
21
|
+
ProofCreditOutcome,
|
|
22
|
+
WebhookSubscriber,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_DEFAULT_BASE_URL = "http://localhost:8080/api/v1"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BoBClient:
|
|
29
|
+
"""Client for the Bank of Bots API.
|
|
30
|
+
|
|
31
|
+
Usage::
|
|
32
|
+
|
|
33
|
+
client = BoBClient(api_key="bok_...", agent_id="<uuid>")
|
|
34
|
+
me = client.get_me()
|
|
35
|
+
|
|
36
|
+
Operator-scoped calls can omit ``agent_id``::
|
|
37
|
+
|
|
38
|
+
op = BoBClient(api_key="bok_...")
|
|
39
|
+
keys = op.list_api_keys()
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
api_key: str,
|
|
45
|
+
agent_id: str | None = None,
|
|
46
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
47
|
+
timeout: float = 30.0,
|
|
48
|
+
):
|
|
49
|
+
self.api_key = api_key
|
|
50
|
+
self.agent_id = agent_id
|
|
51
|
+
self.base_url = base_url.rstrip("/")
|
|
52
|
+
self._client = httpx.Client(
|
|
53
|
+
base_url=self.base_url,
|
|
54
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# -- Context manager --
|
|
59
|
+
|
|
60
|
+
def __enter__(self) -> "BoBClient":
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def __exit__(self, *args: Any) -> None:
|
|
64
|
+
self.close()
|
|
65
|
+
|
|
66
|
+
def close(self) -> None:
|
|
67
|
+
self._client.close()
|
|
68
|
+
|
|
69
|
+
# -- Private helpers --
|
|
70
|
+
|
|
71
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
72
|
+
resp = self._client.request(method, path, **kwargs)
|
|
73
|
+
if resp.status_code == 401:
|
|
74
|
+
raise AuthenticationError()
|
|
75
|
+
if resp.status_code == 403:
|
|
76
|
+
body = resp.json()
|
|
77
|
+
raise ForbiddenError(body.get("error", "access denied"))
|
|
78
|
+
if resp.status_code == 404:
|
|
79
|
+
body = resp.json()
|
|
80
|
+
raise NotFoundError(body.get("error", "not found"))
|
|
81
|
+
if resp.status_code >= 400:
|
|
82
|
+
body = resp.json()
|
|
83
|
+
raise BoBError(body.get("error", "request failed"), status_code=resp.status_code)
|
|
84
|
+
return resp.json()
|
|
85
|
+
|
|
86
|
+
def _agent_path(self, suffix: str = "") -> str:
|
|
87
|
+
if not self.agent_id:
|
|
88
|
+
raise ValueError("agent_id is required for agent-scoped endpoints")
|
|
89
|
+
return f"/agents/{self.agent_id}{suffix}"
|
|
90
|
+
|
|
91
|
+
# -- Private parse helpers --
|
|
92
|
+
|
|
93
|
+
def _parse_intent(self, data: dict[str, Any]) -> PaymentIntent:
|
|
94
|
+
return PaymentIntent(**{k: data[k] for k in PaymentIntent.__dataclass_fields__ if k in data})
|
|
95
|
+
|
|
96
|
+
def _parse_intent_proof(self, data: dict[str, Any]) -> PaymentIntentProof:
|
|
97
|
+
return PaymentIntentProof(**{k: data[k] for k in PaymentIntentProof.__dataclass_fields__ if k in data})
|
|
98
|
+
|
|
99
|
+
def _parse_node_binding(self, data: dict[str, Any]) -> AgentNodeBinding:
|
|
100
|
+
return AgentNodeBinding(**{k: data[k] for k in AgentNodeBinding.__dataclass_fields__ if k in data})
|
|
101
|
+
|
|
102
|
+
def _parse_proof_ownership_challenge(self, data: dict[str, Any]) -> PaymentProofOwnershipChallenge:
|
|
103
|
+
return PaymentProofOwnershipChallenge(
|
|
104
|
+
**{k: data[k] for k in PaymentProofOwnershipChallenge.__dataclass_fields__ if k in data}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def _parse_proof_credit(self, data: dict[str, Any] | None) -> ProofCreditOutcome | None:
|
|
108
|
+
if not isinstance(data, dict):
|
|
109
|
+
return None
|
|
110
|
+
known_fields = [k for k in ProofCreditOutcome.__dataclass_fields__ if k != "extra"]
|
|
111
|
+
parsed = {k: data[k] for k in known_fields if k in data}
|
|
112
|
+
extra = {k: v for k, v in data.items() if k not in ProofCreditOutcome.__dataclass_fields__}
|
|
113
|
+
if extra:
|
|
114
|
+
parsed["extra"] = extra
|
|
115
|
+
return ProofCreditOutcome(**parsed)
|
|
116
|
+
|
|
117
|
+
def _parse_historical_import(self, data: dict[str, Any]) -> HistoricalPaymentProofImport:
|
|
118
|
+
return HistoricalPaymentProofImport(
|
|
119
|
+
**{k: data[k] for k in HistoricalPaymentProofImport.__dataclass_fields__ if k in data}
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# -- Auth --
|
|
123
|
+
|
|
124
|
+
def get_me(self) -> Agent:
|
|
125
|
+
"""Get the authenticated agent's details."""
|
|
126
|
+
data = self._request("GET", self._agent_path())
|
|
127
|
+
return Agent(**{k: data[k] for k in Agent.__dataclass_fields__ if k in data})
|
|
128
|
+
|
|
129
|
+
# -- Agent CRUD --
|
|
130
|
+
|
|
131
|
+
def create_agent(self, name: str, **kwargs: Any) -> Agent:
|
|
132
|
+
"""Create a new agent."""
|
|
133
|
+
payload: dict[str, Any] = {"name": name, **kwargs}
|
|
134
|
+
data = self._request("POST", "/agents", json=payload)
|
|
135
|
+
return Agent(**{k: data[k] for k in Agent.__dataclass_fields__ if k in data})
|
|
136
|
+
|
|
137
|
+
def get_agent(self, agent_id: str) -> Agent:
|
|
138
|
+
"""Get an agent by ID."""
|
|
139
|
+
data = self._request("GET", f"/agents/{agent_id}")
|
|
140
|
+
return Agent(**{k: data[k] for k in Agent.__dataclass_fields__ if k in data})
|
|
141
|
+
|
|
142
|
+
def list_agents(self, limit: int = 50, offset: int = 0) -> list[Agent]:
|
|
143
|
+
"""List agents for the authenticated operator."""
|
|
144
|
+
params: dict[str, int] = {"limit": limit, "offset": offset}
|
|
145
|
+
data = self._request("GET", "/agents", params=params)
|
|
146
|
+
items = data.get("data", data) if isinstance(data, dict) else data
|
|
147
|
+
return [
|
|
148
|
+
Agent(**{k: item[k] for k in Agent.__dataclass_fields__ if k in item})
|
|
149
|
+
for item in items
|
|
150
|
+
if isinstance(item, dict)
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
def approve_agent(
|
|
154
|
+
self,
|
|
155
|
+
agent_id: str,
|
|
156
|
+
seed_amount: int = 0,
|
|
157
|
+
seed_currency: str | None = None,
|
|
158
|
+
seed_wallet_id: str | None = None,
|
|
159
|
+
) -> dict[str, Any]:
|
|
160
|
+
"""Approve an agent and optionally seed starter balance."""
|
|
161
|
+
payload: dict[str, Any] = {}
|
|
162
|
+
if seed_amount > 0:
|
|
163
|
+
payload["seed_amount"] = seed_amount
|
|
164
|
+
if seed_currency:
|
|
165
|
+
payload["seed_currency"] = seed_currency
|
|
166
|
+
if seed_wallet_id:
|
|
167
|
+
payload["seed_wallet_id"] = seed_wallet_id
|
|
168
|
+
return self._request("POST", f"/agents/{agent_id}/approve", json=payload)
|
|
169
|
+
|
|
170
|
+
def batch_agents(self, items: list[dict[str, Any]]) -> dict[str, Any]:
|
|
171
|
+
"""Create multiple agents in a single request."""
|
|
172
|
+
payload: dict[str, Any] = {"items": items}
|
|
173
|
+
data = self._request("POST", "/agents/batch", json=payload)
|
|
174
|
+
return data if isinstance(data, dict) else {}
|
|
175
|
+
|
|
176
|
+
def suggest_agent_name(self) -> str:
|
|
177
|
+
"""Get a suggested agent name."""
|
|
178
|
+
data = self._request("GET", "/agents/suggest-name")
|
|
179
|
+
data_map = data if isinstance(data, dict) else {}
|
|
180
|
+
return str(data_map.get("name", ""))
|
|
181
|
+
|
|
182
|
+
# -- Payment intents --
|
|
183
|
+
|
|
184
|
+
def create_payment_intent(
|
|
185
|
+
self,
|
|
186
|
+
amount: int,
|
|
187
|
+
destination_type: str,
|
|
188
|
+
destination_ref: str,
|
|
189
|
+
currency: str = "BTC",
|
|
190
|
+
auto_execute: bool = True,
|
|
191
|
+
priority: str = "balanced",
|
|
192
|
+
execution_mode: str = "auto",
|
|
193
|
+
pinned_rail: str | None = None,
|
|
194
|
+
pinned_wallet_id: str | None = None,
|
|
195
|
+
max_fee: int = 0,
|
|
196
|
+
latest_settlement_by: str | None = None,
|
|
197
|
+
) -> dict[str, Any]:
|
|
198
|
+
"""Create a payment intent, optionally auto-executing the best route.
|
|
199
|
+
|
|
200
|
+
Returns the raw response dict containing ``intent``, ``quotes``, and
|
|
201
|
+
optionally ``payment`` keys.
|
|
202
|
+
"""
|
|
203
|
+
payload: dict[str, Any] = {
|
|
204
|
+
"destination_type": destination_type,
|
|
205
|
+
"destination_ref": destination_ref,
|
|
206
|
+
"amount": amount,
|
|
207
|
+
"currency": currency,
|
|
208
|
+
"auto_execute": auto_execute,
|
|
209
|
+
"priority": priority,
|
|
210
|
+
"execution_mode": execution_mode,
|
|
211
|
+
}
|
|
212
|
+
if pinned_rail:
|
|
213
|
+
payload["pinned_rail"] = pinned_rail
|
|
214
|
+
if pinned_wallet_id:
|
|
215
|
+
payload["pinned_wallet_id"] = pinned_wallet_id
|
|
216
|
+
if max_fee > 0:
|
|
217
|
+
payload["max_fee"] = max_fee
|
|
218
|
+
if latest_settlement_by:
|
|
219
|
+
payload["latest_settlement_by"] = latest_settlement_by
|
|
220
|
+
data = self._request("POST", self._agent_path("/payment-intents"), json=payload)
|
|
221
|
+
return data if isinstance(data, dict) else {}
|
|
222
|
+
|
|
223
|
+
def quote_payment_intent(
|
|
224
|
+
self,
|
|
225
|
+
amount: int,
|
|
226
|
+
destination_type: str,
|
|
227
|
+
destination_ref: str,
|
|
228
|
+
currency: str = "BTC",
|
|
229
|
+
priority: str = "balanced",
|
|
230
|
+
execution_mode: str = "auto",
|
|
231
|
+
pinned_rail: str | None = None,
|
|
232
|
+
pinned_wallet_id: str | None = None,
|
|
233
|
+
max_fee: int = 0,
|
|
234
|
+
latest_settlement_by: str | None = None,
|
|
235
|
+
) -> dict[str, Any]:
|
|
236
|
+
"""Quote a payment intent without executing it.
|
|
237
|
+
|
|
238
|
+
Returns the raw response dict containing ``intent`` and ``quotes`` keys.
|
|
239
|
+
"""
|
|
240
|
+
payload: dict[str, Any] = {
|
|
241
|
+
"destination_type": destination_type,
|
|
242
|
+
"destination_ref": destination_ref,
|
|
243
|
+
"amount": amount,
|
|
244
|
+
"currency": currency,
|
|
245
|
+
"auto_execute": False,
|
|
246
|
+
"priority": priority,
|
|
247
|
+
"execution_mode": execution_mode,
|
|
248
|
+
}
|
|
249
|
+
if pinned_rail:
|
|
250
|
+
payload["pinned_rail"] = pinned_rail
|
|
251
|
+
if pinned_wallet_id:
|
|
252
|
+
payload["pinned_wallet_id"] = pinned_wallet_id
|
|
253
|
+
if max_fee > 0:
|
|
254
|
+
payload["max_fee"] = max_fee
|
|
255
|
+
if latest_settlement_by:
|
|
256
|
+
payload["latest_settlement_by"] = latest_settlement_by
|
|
257
|
+
data = self._request("POST", self._agent_path("/payment-intents/quote"), json=payload)
|
|
258
|
+
return data if isinstance(data, dict) else {}
|
|
259
|
+
|
|
260
|
+
def execute_payment_intent(
|
|
261
|
+
self,
|
|
262
|
+
intent_id: str,
|
|
263
|
+
quote_id: str | None = None,
|
|
264
|
+
description: str | None = None,
|
|
265
|
+
) -> dict[str, Any]:
|
|
266
|
+
"""Execute a previously quoted payment intent.
|
|
267
|
+
|
|
268
|
+
Returns the raw response dict containing ``intent``, ``quote``, and
|
|
269
|
+
``payment`` keys.
|
|
270
|
+
"""
|
|
271
|
+
payload: dict[str, Any] = {}
|
|
272
|
+
if quote_id:
|
|
273
|
+
payload["quote_id"] = quote_id
|
|
274
|
+
if description:
|
|
275
|
+
payload["description"] = description
|
|
276
|
+
data = self._request(
|
|
277
|
+
"POST",
|
|
278
|
+
self._agent_path(f"/payment-intents/{intent_id}/execute"),
|
|
279
|
+
json=payload,
|
|
280
|
+
)
|
|
281
|
+
return data if isinstance(data, dict) else {}
|
|
282
|
+
|
|
283
|
+
def list_payment_intents(self, limit: int = 50, offset: int = 0) -> list[PaymentIntent]:
|
|
284
|
+
"""List payment intents for this agent."""
|
|
285
|
+
params: dict[str, int] = {"limit": limit, "offset": offset}
|
|
286
|
+
data = self._request("GET", self._agent_path("/payment-intents"), params=params)
|
|
287
|
+
items = data.get("data", data) if isinstance(data, dict) else data
|
|
288
|
+
return [
|
|
289
|
+
self._parse_intent(item)
|
|
290
|
+
for item in items
|
|
291
|
+
if isinstance(item, dict)
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
def get_payment_intent(self, intent_id: str) -> dict[str, Any]:
|
|
295
|
+
"""Get a payment intent with its route quotes.
|
|
296
|
+
|
|
297
|
+
Returns the raw response dict containing ``intent`` and ``quotes`` keys.
|
|
298
|
+
"""
|
|
299
|
+
data = self._request("GET", self._agent_path(f"/payment-intents/{intent_id}"))
|
|
300
|
+
return data if isinstance(data, dict) else {}
|
|
301
|
+
|
|
302
|
+
# -- Proof submission --
|
|
303
|
+
|
|
304
|
+
def create_proof_ownership_challenge(
|
|
305
|
+
self,
|
|
306
|
+
intent_id: str,
|
|
307
|
+
proof_type: str,
|
|
308
|
+
proof_ref: str,
|
|
309
|
+
) -> PaymentProofOwnershipChallenge:
|
|
310
|
+
"""Create a one-time ownership challenge bound to intent + proof context."""
|
|
311
|
+
payload: dict[str, Any] = {
|
|
312
|
+
"proof_type": (proof_type or "").strip().lower(),
|
|
313
|
+
"proof_ref": (proof_ref or "").strip(),
|
|
314
|
+
}
|
|
315
|
+
data = self._request(
|
|
316
|
+
"POST",
|
|
317
|
+
self._agent_path(f"/payment-intents/{intent_id}/proof-ownership/challenge"),
|
|
318
|
+
json=payload,
|
|
319
|
+
)
|
|
320
|
+
return self._parse_proof_ownership_challenge(data.get("challenge", {}))
|
|
321
|
+
|
|
322
|
+
def submit_payment_intent_proof(
|
|
323
|
+
self,
|
|
324
|
+
intent_id: str,
|
|
325
|
+
proof_type: str,
|
|
326
|
+
proof_ref: str,
|
|
327
|
+
ownership_challenge_id: str,
|
|
328
|
+
ownership_signature: str,
|
|
329
|
+
metadata: dict[str, Any] | None = None,
|
|
330
|
+
) -> tuple[PaymentIntent, PaymentIntentProof]:
|
|
331
|
+
"""Submit non-custodial proof for a BTC payment intent.
|
|
332
|
+
|
|
333
|
+
Supported proof_type values:
|
|
334
|
+
- btc_onchain_tx: on-chain transaction ID
|
|
335
|
+
- btc_lightning_payment_hash: lightning payment hash
|
|
336
|
+
- btc_lightning_preimage: lightning preimage (pass preimage in
|
|
337
|
+
metadata["preimage"], optionally include BOLT11 invoice in
|
|
338
|
+
metadata["invoice"] for stronger verification)
|
|
339
|
+
"""
|
|
340
|
+
payload: dict[str, Any] = {
|
|
341
|
+
"proof_type": (proof_type or "").strip().lower(),
|
|
342
|
+
"proof_ref": (proof_ref or "").strip(),
|
|
343
|
+
"ownership_challenge_id": (ownership_challenge_id or "").strip(),
|
|
344
|
+
"ownership_signature": (ownership_signature or "").strip(),
|
|
345
|
+
}
|
|
346
|
+
if payload["ownership_challenge_id"] == "" or payload["ownership_signature"] == "":
|
|
347
|
+
raise ValueError("ownership_challenge_id and ownership_signature are required")
|
|
348
|
+
if metadata is not None:
|
|
349
|
+
payload["metadata"] = metadata
|
|
350
|
+
data = self._request("POST", self._agent_path(f"/payment-intents/{intent_id}/proofs"), json=payload)
|
|
351
|
+
intent = self._parse_intent(data.get("intent", {}))
|
|
352
|
+
proof = self._parse_intent_proof(data.get("proof", {}))
|
|
353
|
+
proof.credit = self._parse_proof_credit(data.get("credit"))
|
|
354
|
+
return intent, proof
|
|
355
|
+
|
|
356
|
+
def list_payment_intent_proofs(self, intent_id: str) -> tuple[PaymentIntent, list[PaymentIntentProof]]:
|
|
357
|
+
"""List submitted proofs for a payment intent."""
|
|
358
|
+
data = self._request("GET", self._agent_path(f"/payment-intents/{intent_id}/proofs"))
|
|
359
|
+
intent = self._parse_intent(data.get("intent", {}))
|
|
360
|
+
proofs = [
|
|
361
|
+
self._parse_intent_proof(item)
|
|
362
|
+
for item in data.get("proofs", [])
|
|
363
|
+
if isinstance(item, dict)
|
|
364
|
+
]
|
|
365
|
+
return intent, proofs
|
|
366
|
+
|
|
367
|
+
def reverify_payment_intent_proof(self, intent_id: str, proof_id: str) -> PaymentIntentProof:
|
|
368
|
+
"""Re-run verification on an existing payment intent proof."""
|
|
369
|
+
data = self._request(
|
|
370
|
+
"POST",
|
|
371
|
+
self._agent_path(f"/payment-intents/{intent_id}/proofs/{proof_id}/reverify"),
|
|
372
|
+
json={},
|
|
373
|
+
)
|
|
374
|
+
proof = self._parse_intent_proof(data.get("proof", {}))
|
|
375
|
+
proof.credit = self._parse_proof_credit(data.get("credit"))
|
|
376
|
+
return proof
|
|
377
|
+
|
|
378
|
+
# -- Historical proof imports --
|
|
379
|
+
|
|
380
|
+
def import_payment_proof(
|
|
381
|
+
self,
|
|
382
|
+
proof_type: str,
|
|
383
|
+
proof_ref: str,
|
|
384
|
+
rail: str,
|
|
385
|
+
amount: int,
|
|
386
|
+
currency: str = "BTC",
|
|
387
|
+
direction: str = "outbound",
|
|
388
|
+
occurred_at: str | None = None,
|
|
389
|
+
counterparty_ref: str | None = None,
|
|
390
|
+
metadata: dict[str, Any] | None = None,
|
|
391
|
+
) -> HistoricalPaymentProofImport:
|
|
392
|
+
"""Import a historical BTC payment proof for credit building.
|
|
393
|
+
|
|
394
|
+
Supported proof_type values:
|
|
395
|
+
- btc_onchain_tx: on-chain transaction ID
|
|
396
|
+
- btc_lightning_payment_hash: lightning payment hash
|
|
397
|
+
- btc_lightning_preimage: lightning preimage (pass preimage in
|
|
398
|
+
metadata["preimage"], optionally include BOLT11 invoice in
|
|
399
|
+
metadata["invoice"] for stronger verification)
|
|
400
|
+
"""
|
|
401
|
+
payload: dict[str, Any] = {
|
|
402
|
+
"proof_type": (proof_type or "").strip().lower(),
|
|
403
|
+
"proof_ref": (proof_ref or "").strip(),
|
|
404
|
+
"rail": (rail or "").strip().lower(),
|
|
405
|
+
"currency": (currency or "BTC").strip().upper(),
|
|
406
|
+
"amount": amount,
|
|
407
|
+
"direction": (direction or "outbound").strip().lower(),
|
|
408
|
+
}
|
|
409
|
+
if occurred_at:
|
|
410
|
+
payload["occurred_at"] = occurred_at
|
|
411
|
+
if counterparty_ref:
|
|
412
|
+
payload["counterparty_ref"] = counterparty_ref
|
|
413
|
+
if metadata is not None:
|
|
414
|
+
payload["metadata"] = metadata
|
|
415
|
+
data = self._request("POST", self._agent_path("/credit/imports/payment-proofs"), json=payload)
|
|
416
|
+
imported = self._parse_historical_import(data.get("import", {}))
|
|
417
|
+
imported.credit = self._parse_proof_credit(data.get("credit"))
|
|
418
|
+
return imported
|
|
419
|
+
|
|
420
|
+
def list_payment_proof_imports(
|
|
421
|
+
self,
|
|
422
|
+
limit: int = 50,
|
|
423
|
+
offset: int = 0,
|
|
424
|
+
) -> list[HistoricalPaymentProofImport]:
|
|
425
|
+
"""List historical BTC payment proof imports for this agent."""
|
|
426
|
+
params: dict[str, int] = {"limit": limit, "offset": offset}
|
|
427
|
+
data = self._request("GET", self._agent_path("/credit/imports/payment-proofs"), params=params)
|
|
428
|
+
items = data.get("data", data) if isinstance(data, dict) else data
|
|
429
|
+
return [
|
|
430
|
+
self._parse_historical_import(item)
|
|
431
|
+
for item in items
|
|
432
|
+
if isinstance(item, dict)
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
def reverify_payment_proof_import(self, import_id: str) -> HistoricalPaymentProofImport:
|
|
436
|
+
"""Re-run verification on a historical payment proof import."""
|
|
437
|
+
data = self._request(
|
|
438
|
+
"POST",
|
|
439
|
+
self._agent_path(f"/credit/imports/payment-proofs/{import_id}/reverify"),
|
|
440
|
+
json={},
|
|
441
|
+
)
|
|
442
|
+
imported = self._parse_historical_import(data.get("import", {}))
|
|
443
|
+
imported.credit = self._parse_proof_credit(data.get("credit"))
|
|
444
|
+
return imported
|
|
445
|
+
|
|
446
|
+
# -- Agent credit --
|
|
447
|
+
|
|
448
|
+
def get_agent_credit(self) -> AgentCreditResponse:
|
|
449
|
+
"""Fetch the agent's credit score, tier, and effective policy limits."""
|
|
450
|
+
data = self._request("GET", self._agent_path("/credit"))
|
|
451
|
+
data_map = data if isinstance(data, dict) else {}
|
|
452
|
+
return AgentCreditResponse(
|
|
453
|
+
**{k: data_map[k] for k in AgentCreditResponse.__dataclass_fields__ if k in data_map}
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
def list_agent_credit_events(self, limit: int = 50, offset: int = 0) -> list[CreditEvent]:
|
|
457
|
+
"""Fetch paginated credit event history for the agent."""
|
|
458
|
+
data = self._request(
|
|
459
|
+
"GET",
|
|
460
|
+
self._agent_path("/credit/events"),
|
|
461
|
+
params={"limit": limit, "offset": offset},
|
|
462
|
+
)
|
|
463
|
+
items = data.get("data", []) if isinstance(data, dict) else []
|
|
464
|
+
return [
|
|
465
|
+
CreditEvent(**{k: item[k] for k in CreditEvent.__dataclass_fields__ if k in item})
|
|
466
|
+
for item in items
|
|
467
|
+
]
|
|
468
|
+
|
|
469
|
+
# -- BOB Score --
|
|
470
|
+
|
|
471
|
+
def get_score(self, agent_id: str | None = None) -> dict[str, Any]:
|
|
472
|
+
"""Get the BOB Score for an agent."""
|
|
473
|
+
target = agent_id or self.agent_id
|
|
474
|
+
if not target:
|
|
475
|
+
raise ValueError("agent_id is required")
|
|
476
|
+
data = self._request("GET", f"/agents/{target}/score")
|
|
477
|
+
return data if isinstance(data, dict) else {}
|
|
478
|
+
|
|
479
|
+
def get_score_composition(self, agent_id: str | None = None) -> dict[str, Any]:
|
|
480
|
+
"""Get the detailed BOB Score composition breakdown for an agent."""
|
|
481
|
+
target = agent_id or self.agent_id
|
|
482
|
+
if not target:
|
|
483
|
+
raise ValueError("agent_id is required")
|
|
484
|
+
data = self._request("GET", f"/agents/{target}/score/composition")
|
|
485
|
+
return data if isinstance(data, dict) else {}
|
|
486
|
+
|
|
487
|
+
def update_signal_visibility(
|
|
488
|
+
self,
|
|
489
|
+
signals: dict[str, bool],
|
|
490
|
+
agent_id: str | None = None,
|
|
491
|
+
) -> dict[str, Any]:
|
|
492
|
+
"""Update which BOB Score signals are publicly visible for an agent."""
|
|
493
|
+
target = agent_id or self.agent_id
|
|
494
|
+
if not target:
|
|
495
|
+
raise ValueError("agent_id is required")
|
|
496
|
+
data = self._request("PATCH", f"/agents/{target}/score/signals", json={"signals": signals})
|
|
497
|
+
return data if isinstance(data, dict) else {}
|
|
498
|
+
|
|
499
|
+
def get_leaderboard(
|
|
500
|
+
self,
|
|
501
|
+
limit: int = 50,
|
|
502
|
+
offset: int = 0,
|
|
503
|
+
) -> dict[str, Any]:
|
|
504
|
+
"""Get the global BOB Score leaderboard."""
|
|
505
|
+
params: dict[str, int] = {"limit": limit, "offset": offset}
|
|
506
|
+
data = self._request("GET", "/leaderboard", params=params)
|
|
507
|
+
return data if isinstance(data, dict) else {}
|
|
508
|
+
|
|
509
|
+
# -- Wallet binding --
|
|
510
|
+
|
|
511
|
+
def create_wallet_binding_challenge(
|
|
512
|
+
self,
|
|
513
|
+
chain: str,
|
|
514
|
+
address: str,
|
|
515
|
+
agent_id: str | None = None,
|
|
516
|
+
) -> dict[str, Any]:
|
|
517
|
+
"""Create a challenge for binding an EVM wallet address to an agent."""
|
|
518
|
+
target = agent_id or self.agent_id
|
|
519
|
+
if not target:
|
|
520
|
+
raise ValueError("agent_id is required")
|
|
521
|
+
payload: dict[str, Any] = {
|
|
522
|
+
"chain": (chain or "").strip().lower(),
|
|
523
|
+
"address": (address or "").strip(),
|
|
524
|
+
}
|
|
525
|
+
data = self._request("POST", f"/agents/{target}/wallet-bindings/challenge", json=payload)
|
|
526
|
+
return data if isinstance(data, dict) else {}
|
|
527
|
+
|
|
528
|
+
def verify_wallet_binding(
|
|
529
|
+
self,
|
|
530
|
+
challenge_id: str,
|
|
531
|
+
signature: str,
|
|
532
|
+
agent_id: str | None = None,
|
|
533
|
+
) -> dict[str, Any]:
|
|
534
|
+
"""Verify an EVM wallet binding by submitting a signed challenge."""
|
|
535
|
+
target = agent_id or self.agent_id
|
|
536
|
+
if not target:
|
|
537
|
+
raise ValueError("agent_id is required")
|
|
538
|
+
payload: dict[str, Any] = {
|
|
539
|
+
"challenge_id": (challenge_id or "").strip(),
|
|
540
|
+
"signature": (signature or "").strip(),
|
|
541
|
+
}
|
|
542
|
+
data = self._request("POST", f"/agents/{target}/wallet-bindings/verify", json=payload)
|
|
543
|
+
return data if isinstance(data, dict) else {}
|
|
544
|
+
|
|
545
|
+
def create_node_binding_challenge(self, wallet_id: str | None = None) -> PaymentProofOwnershipChallenge:
|
|
546
|
+
"""Create a challenge for binding a Lightning node to this agent."""
|
|
547
|
+
payload: dict[str, Any] = {}
|
|
548
|
+
if wallet_id:
|
|
549
|
+
payload["wallet_id"] = wallet_id
|
|
550
|
+
data = self._request(
|
|
551
|
+
"POST",
|
|
552
|
+
self._agent_path("/node-bindings/lightning/challenge"),
|
|
553
|
+
json=payload,
|
|
554
|
+
)
|
|
555
|
+
return self._parse_proof_ownership_challenge(data.get("challenge", {}))
|
|
556
|
+
|
|
557
|
+
def verify_node_binding(self, challenge_id: str, signature: str) -> AgentNodeBinding:
|
|
558
|
+
"""Verify a Lightning node binding by submitting a signed challenge."""
|
|
559
|
+
payload: dict[str, Any] = {
|
|
560
|
+
"challenge_id": (challenge_id or "").strip(),
|
|
561
|
+
"signature": (signature or "").strip(),
|
|
562
|
+
}
|
|
563
|
+
data = self._request(
|
|
564
|
+
"POST",
|
|
565
|
+
self._agent_path("/node-bindings/lightning/verify"),
|
|
566
|
+
json=payload,
|
|
567
|
+
)
|
|
568
|
+
return self._parse_node_binding(data.get("binding", {}))
|
|
569
|
+
|
|
570
|
+
# -- Social --
|
|
571
|
+
|
|
572
|
+
def connect_social(
|
|
573
|
+
self,
|
|
574
|
+
platform: str,
|
|
575
|
+
access_token: str,
|
|
576
|
+
agent_id: str | None = None,
|
|
577
|
+
) -> dict[str, Any]:
|
|
578
|
+
"""Connect a social account to an agent for BOB Score signals."""
|
|
579
|
+
target = agent_id or self.agent_id
|
|
580
|
+
if not target:
|
|
581
|
+
raise ValueError("agent_id is required")
|
|
582
|
+
payload: dict[str, Any] = {
|
|
583
|
+
"platform": (platform or "").strip().lower(),
|
|
584
|
+
"access_token": (access_token or "").strip(),
|
|
585
|
+
}
|
|
586
|
+
data = self._request("POST", f"/agents/{target}/social/connect", json=payload)
|
|
587
|
+
return data if isinstance(data, dict) else {}
|
|
588
|
+
|
|
589
|
+
# -- Webhooks --
|
|
590
|
+
|
|
591
|
+
def create_agent_webhook(self, url: str, events: list[str] | None = None) -> WebhookSubscriber:
|
|
592
|
+
"""Create an agent-scoped webhook subscriber."""
|
|
593
|
+
payload: dict[str, Any] = {"url": (url or "").strip()}
|
|
594
|
+
if events is not None:
|
|
595
|
+
payload["events"] = [str(event).strip() for event in events if str(event).strip()]
|
|
596
|
+
data = self._request("POST", self._agent_path("/webhooks"), json=payload)
|
|
597
|
+
data_map = data if isinstance(data, dict) else {}
|
|
598
|
+
return WebhookSubscriber(
|
|
599
|
+
**{k: data_map[k] for k in WebhookSubscriber.__dataclass_fields__ if k in data_map}
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
def list_agent_webhooks(self) -> list[WebhookSubscriber]:
|
|
603
|
+
"""List webhook subscribers scoped to this agent."""
|
|
604
|
+
data = self._request("GET", self._agent_path("/webhooks"))
|
|
605
|
+
items = data if isinstance(data, list) else []
|
|
606
|
+
return [
|
|
607
|
+
WebhookSubscriber(
|
|
608
|
+
**{k: item[k] for k in WebhookSubscriber.__dataclass_fields__ if k in item}
|
|
609
|
+
)
|
|
610
|
+
for item in items
|
|
611
|
+
if isinstance(item, dict)
|
|
612
|
+
]
|
|
613
|
+
|
|
614
|
+
def get_agent_webhook(self, webhook_id: str) -> WebhookSubscriber:
|
|
615
|
+
"""Fetch one agent webhook subscriber by ID."""
|
|
616
|
+
data = self._request("GET", self._agent_path(f"/webhooks/{webhook_id}"))
|
|
617
|
+
data_map = data if isinstance(data, dict) else {}
|
|
618
|
+
return WebhookSubscriber(
|
|
619
|
+
**{k: data_map[k] for k in WebhookSubscriber.__dataclass_fields__ if k in data_map}
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
def update_agent_webhook(
|
|
623
|
+
self,
|
|
624
|
+
webhook_id: str,
|
|
625
|
+
*,
|
|
626
|
+
url: str | None = None,
|
|
627
|
+
events: list[str] | None = None,
|
|
628
|
+
active: bool | None = None,
|
|
629
|
+
) -> WebhookSubscriber:
|
|
630
|
+
"""Update an agent-scoped webhook subscriber."""
|
|
631
|
+
payload: dict[str, Any] = {}
|
|
632
|
+
if url is not None:
|
|
633
|
+
payload["url"] = url.strip()
|
|
634
|
+
if events is not None:
|
|
635
|
+
payload["events"] = [str(event).strip() for event in events if str(event).strip()]
|
|
636
|
+
if active is not None:
|
|
637
|
+
payload["active"] = bool(active)
|
|
638
|
+
data = self._request("PATCH", self._agent_path(f"/webhooks/{webhook_id}"), json=payload)
|
|
639
|
+
data_map = data if isinstance(data, dict) else {}
|
|
640
|
+
return WebhookSubscriber(
|
|
641
|
+
**{k: data_map[k] for k in WebhookSubscriber.__dataclass_fields__ if k in data_map}
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
def delete_agent_webhook(self, webhook_id: str) -> dict[str, Any]:
|
|
645
|
+
"""Delete an agent-scoped webhook subscriber."""
|
|
646
|
+
data = self._request("DELETE", self._agent_path(f"/webhooks/{webhook_id}"))
|
|
647
|
+
return data if isinstance(data, dict) else {}
|
|
648
|
+
|
|
649
|
+
# -- Inbox --
|
|
650
|
+
|
|
651
|
+
def get_agent_inbox(
|
|
652
|
+
self,
|
|
653
|
+
status: str | None = None,
|
|
654
|
+
limit: int = 50,
|
|
655
|
+
offset: int = 0,
|
|
656
|
+
) -> list[InboxEvent]:
|
|
657
|
+
"""List inbox events for this agent."""
|
|
658
|
+
params: dict[str, str | int] = {"limit": limit, "offset": offset}
|
|
659
|
+
if status:
|
|
660
|
+
params["status"] = status
|
|
661
|
+
data = self._request("GET", self._agent_path("/inbox"), params=params)
|
|
662
|
+
items = data.get("data", data) if isinstance(data, dict) else data
|
|
663
|
+
return [
|
|
664
|
+
InboxEvent(**{k: item[k] for k in InboxEvent.__dataclass_fields__ if k in item})
|
|
665
|
+
for item in items
|
|
666
|
+
if isinstance(item, dict)
|
|
667
|
+
]
|
|
668
|
+
|
|
669
|
+
def ack_inbox_event(self, event_id: str) -> InboxEvent:
|
|
670
|
+
"""Acknowledge an inbox event."""
|
|
671
|
+
data = self._request("POST", self._agent_path(f"/inbox/{event_id}/ack"))
|
|
672
|
+
return InboxEvent(**{k: data[k] for k in InboxEvent.__dataclass_fields__ if k in data})
|
|
673
|
+
|
|
674
|
+
def list_agent_events(self, limit: int = 30, offset: int = 0) -> list[dict[str, Any]]:
|
|
675
|
+
"""List agent system events (paginated)."""
|
|
676
|
+
data = self._request(
|
|
677
|
+
"GET",
|
|
678
|
+
self._agent_path("/events"),
|
|
679
|
+
params={"limit": limit, "offset": offset},
|
|
680
|
+
)
|
|
681
|
+
items = data.get("data", []) if isinstance(data, dict) else []
|
|
682
|
+
return [item for item in items if isinstance(item, dict)]
|
|
683
|
+
|
|
684
|
+
# -- API keys --
|
|
685
|
+
|
|
686
|
+
def list_api_keys(
|
|
687
|
+
self,
|
|
688
|
+
include_revoked: bool = False,
|
|
689
|
+
limit: int = 50,
|
|
690
|
+
) -> list[APIKeyRecord]:
|
|
691
|
+
"""List API keys for the authenticated operator."""
|
|
692
|
+
if limit <= 0:
|
|
693
|
+
raise ValueError("limit must be > 0")
|
|
694
|
+
params: dict[str, Any] = {"limit": limit}
|
|
695
|
+
if include_revoked:
|
|
696
|
+
params["include_revoked"] = "true"
|
|
697
|
+
data = self._request("GET", "/operators/me/api-keys", params=params)
|
|
698
|
+
items = data if isinstance(data, list) else []
|
|
699
|
+
return [
|
|
700
|
+
APIKeyRecord(**{k: item[k] for k in APIKeyRecord.__dataclass_fields__ if k in item})
|
|
701
|
+
for item in items
|
|
702
|
+
if isinstance(item, dict)
|
|
703
|
+
]
|
|
704
|
+
|
|
705
|
+
def create_api_key(
|
|
706
|
+
self,
|
|
707
|
+
name: str = "",
|
|
708
|
+
scopes: list[str] | None = None,
|
|
709
|
+
) -> dict[str, Any]:
|
|
710
|
+
"""Create a new API key with optional scopes.
|
|
711
|
+
|
|
712
|
+
Returns a dict with ``api_key`` (the raw secret, shown once) and
|
|
713
|
+
``key`` (an ``APIKeyRecord``-shaped dict).
|
|
714
|
+
"""
|
|
715
|
+
payload: dict[str, Any] = {}
|
|
716
|
+
if (name or "").strip():
|
|
717
|
+
payload["name"] = name.strip()
|
|
718
|
+
if scopes:
|
|
719
|
+
payload["scopes"] = [str(scope).strip().lower() for scope in scopes if str(scope).strip()]
|
|
720
|
+
data = self._request("POST", "/operators/me/api-keys", json=payload)
|
|
721
|
+
return data if isinstance(data, dict) else {}
|
|
722
|
+
|
|
723
|
+
def revoke_api_key(self, key_id: str) -> dict[str, Any]:
|
|
724
|
+
"""Revoke an API key by ID."""
|
|
725
|
+
normalized = (key_id or "").strip()
|
|
726
|
+
if not normalized:
|
|
727
|
+
raise ValueError("key_id is required")
|
|
728
|
+
data = self._request("POST", f"/operators/me/api-keys/{normalized}/revoke", json={})
|
|
729
|
+
return data if isinstance(data, dict) else {}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Typed exceptions for the Bank of Bots SDK."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class BoBError(Exception):
|
|
5
|
+
"""Base exception for all SDK errors."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthenticationError(BoBError):
|
|
13
|
+
"""Raised on 401 — invalid or missing API key."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str = "invalid or missing api key"):
|
|
16
|
+
super().__init__(message, status_code=401)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ForbiddenError(BoBError):
|
|
20
|
+
"""Raised on 403 — valid key but insufficient permissions."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, message: str = "access denied"):
|
|
23
|
+
super().__init__(message, status_code=403)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotFoundError(BoBError):
|
|
27
|
+
"""Raised on 404 — resource does not exist."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str = "resource not found"):
|
|
30
|
+
super().__init__(message, status_code=404)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ConflictError(BoBError):
|
|
34
|
+
"""Raised on 409 — request conflicts with current server state.
|
|
35
|
+
|
|
36
|
+
Common cause: attempting to create a second agent for an operator that
|
|
37
|
+
already has one (one-agent-per-operator constraint).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str = "resource conflict"):
|
|
41
|
+
super().__init__(message, status_code=409)
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Data types returned by the Bank of Bots API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Agent:
|
|
11
|
+
id: str
|
|
12
|
+
name: str
|
|
13
|
+
operator_id: str
|
|
14
|
+
status: str
|
|
15
|
+
budget: int
|
|
16
|
+
created_at: str
|
|
17
|
+
updated_at: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class APIKeyRecord:
|
|
22
|
+
id: str
|
|
23
|
+
key_prefix: str
|
|
24
|
+
role: str
|
|
25
|
+
scopes: list[str] = field(default_factory=list)
|
|
26
|
+
name: str = ""
|
|
27
|
+
agent_id: str = ""
|
|
28
|
+
operator_id: str = ""
|
|
29
|
+
created_at: str = ""
|
|
30
|
+
last_used_at: str = ""
|
|
31
|
+
revoked_at: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class AgentCreditResponse:
|
|
36
|
+
agent_id: str
|
|
37
|
+
score: int
|
|
38
|
+
tier: str
|
|
39
|
+
reason: str
|
|
40
|
+
base_spend_limit: int = 0
|
|
41
|
+
effective_spend_limit: int = 0
|
|
42
|
+
base_rate_limit: int = 0
|
|
43
|
+
effective_rate_limit: int = 0
|
|
44
|
+
tier_multiplier: float = 1.0
|
|
45
|
+
credit_tier_enabled: bool = False
|
|
46
|
+
snapshot_at: str = ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class AgentNodeBinding:
|
|
51
|
+
id: str
|
|
52
|
+
agent_id: str
|
|
53
|
+
wallet_id: str
|
|
54
|
+
rail: str
|
|
55
|
+
node_pubkey: str
|
|
56
|
+
status: str = "verified"
|
|
57
|
+
verified_at: str = ""
|
|
58
|
+
created_at: str = ""
|
|
59
|
+
updated_at: str = ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class CreditEvent:
|
|
64
|
+
id: str
|
|
65
|
+
agent_id: str
|
|
66
|
+
event_type: str
|
|
67
|
+
score_delta: int
|
|
68
|
+
amount: int = 0
|
|
69
|
+
currency: str = ""
|
|
70
|
+
rail: str = ""
|
|
71
|
+
status: str = ""
|
|
72
|
+
reason: str = ""
|
|
73
|
+
metadata: str = "{}"
|
|
74
|
+
created_at: str = ""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class InboxEvent:
|
|
79
|
+
id: str
|
|
80
|
+
agent_id: str
|
|
81
|
+
event_type: str
|
|
82
|
+
amount: int
|
|
83
|
+
currency: str
|
|
84
|
+
rail: str
|
|
85
|
+
status: str
|
|
86
|
+
payment_address_id: str = ""
|
|
87
|
+
payment_id: str = ""
|
|
88
|
+
payment_intent_id: str = ""
|
|
89
|
+
sender_agent_id: str = ""
|
|
90
|
+
metadata: str = "{}"
|
|
91
|
+
created_at: str = ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class PaymentIntent:
|
|
96
|
+
id: str
|
|
97
|
+
agent_id: str
|
|
98
|
+
destination_type: str
|
|
99
|
+
destination_ref: str
|
|
100
|
+
amount: int
|
|
101
|
+
currency: str
|
|
102
|
+
status: str
|
|
103
|
+
operator_id: str = ""
|
|
104
|
+
execution_mode: str = "auto"
|
|
105
|
+
pinned_rail: str = ""
|
|
106
|
+
pinned_wallet_id: str = ""
|
|
107
|
+
priority: str = "balanced"
|
|
108
|
+
max_fee: int = 0
|
|
109
|
+
latest_settlement_by: str = ""
|
|
110
|
+
selected_quote_id: str = ""
|
|
111
|
+
metadata: str = "{}"
|
|
112
|
+
created_at: str = ""
|
|
113
|
+
updated_at: str = ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class PaymentIntentProof:
|
|
118
|
+
id: str
|
|
119
|
+
intent_id: str
|
|
120
|
+
proof_type: str
|
|
121
|
+
proof_ref: str
|
|
122
|
+
verification_status: str
|
|
123
|
+
rail: str = ""
|
|
124
|
+
verification_reason: str = ""
|
|
125
|
+
metadata: str = "{}"
|
|
126
|
+
created_at: str = ""
|
|
127
|
+
verified_at: str = ""
|
|
128
|
+
credit: "ProofCreditOutcome | None" = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class PaymentProofOwnershipChallenge:
|
|
133
|
+
id: str
|
|
134
|
+
challenge_type: str
|
|
135
|
+
agent_id: str
|
|
136
|
+
rail: str
|
|
137
|
+
nonce: str
|
|
138
|
+
message: str
|
|
139
|
+
expires_at: str
|
|
140
|
+
wallet_id: str = ""
|
|
141
|
+
intent_id: str = ""
|
|
142
|
+
proof_type: str = ""
|
|
143
|
+
proof_ref: str = ""
|
|
144
|
+
bound_node_pubkey: str = ""
|
|
145
|
+
used_at: str = ""
|
|
146
|
+
created_at: str = ""
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class ProofCreditOutcome:
|
|
151
|
+
awarded: bool
|
|
152
|
+
delta: int
|
|
153
|
+
tier: str = ""
|
|
154
|
+
score: int = 0
|
|
155
|
+
reason: str = ""
|
|
156
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class HistoricalPaymentProofImport:
|
|
161
|
+
id: str
|
|
162
|
+
agent_id: str
|
|
163
|
+
proof_type: str
|
|
164
|
+
proof_ref: str
|
|
165
|
+
rail: str
|
|
166
|
+
currency: str
|
|
167
|
+
amount: int
|
|
168
|
+
direction: str
|
|
169
|
+
verification_status: str
|
|
170
|
+
confidence_tier: str
|
|
171
|
+
confidence_score: int
|
|
172
|
+
occurred_at: str = ""
|
|
173
|
+
counterparty_ref: str = ""
|
|
174
|
+
verification_reason: str = ""
|
|
175
|
+
metadata: str = "{}"
|
|
176
|
+
created_at: str = ""
|
|
177
|
+
verified_at: str = ""
|
|
178
|
+
credit: ProofCreditOutcome | None = None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass
|
|
182
|
+
class WebhookSubscriber:
|
|
183
|
+
id: str
|
|
184
|
+
operator_id: str
|
|
185
|
+
url: str
|
|
186
|
+
agent_id: str = ""
|
|
187
|
+
events: list[str] = field(default_factory=list)
|
|
188
|
+
secret: str = ""
|
|
189
|
+
active: bool = True
|
|
190
|
+
failure_count: int = 0
|
|
191
|
+
created_at: str = ""
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "bankofbots"
|
|
7
|
+
version = "0.4.2"
|
|
8
|
+
description = "Python SDK for Bank of Bots — economic infrastructure for autonomous agents"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
dependencies = ["httpx>=0.25"]
|
|
11
|
+
|
|
12
|
+
[project.optional-dependencies]
|
|
13
|
+
dev = ["pytest", "respx"]
|
|
14
|
+
|
|
15
|
+
[tool.hatch.build.targets.wheel]
|
|
16
|
+
packages = ["bob"]
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
import respx
|
|
6
|
+
|
|
7
|
+
from bob import BoBClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@respx.mock
|
|
11
|
+
def test_agent_credit_parses_response() -> None:
|
|
12
|
+
route = respx.get(
|
|
13
|
+
"http://localhost:8080/api/v1/agents/agent-1/credit"
|
|
14
|
+
).mock(
|
|
15
|
+
return_value=httpx.Response(
|
|
16
|
+
200,
|
|
17
|
+
json={
|
|
18
|
+
"agent_id": "agent-1",
|
|
19
|
+
"score": 75,
|
|
20
|
+
"tier": "growing",
|
|
21
|
+
"reason": "good history",
|
|
22
|
+
"base_spend_limit": 10000,
|
|
23
|
+
"effective_spend_limit": 12000,
|
|
24
|
+
"base_rate_limit": 20,
|
|
25
|
+
"effective_rate_limit": 24,
|
|
26
|
+
"tier_multiplier": 1.2,
|
|
27
|
+
"credit_tier_enabled": True,
|
|
28
|
+
"snapshot_at": "2026-02-27T00:00:00Z",
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
client = BoBClient(api_key="bok_test", agent_id="agent-1")
|
|
34
|
+
resp = client.get_agent_credit()
|
|
35
|
+
client.close()
|
|
36
|
+
|
|
37
|
+
assert route.called
|
|
38
|
+
assert resp.agent_id == "agent-1"
|
|
39
|
+
assert resp.score == 75
|
|
40
|
+
assert resp.tier == "growing"
|
|
41
|
+
assert resp.effective_spend_limit == 12000
|
|
42
|
+
assert resp.tier_multiplier == 1.2
|
|
43
|
+
assert resp.credit_tier_enabled is True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@respx.mock
|
|
47
|
+
def test_agent_credit_events_parses_response() -> None:
|
|
48
|
+
route = respx.get(
|
|
49
|
+
"http://localhost:8080/api/v1/agents/agent-1/credit/events",
|
|
50
|
+
params={"limit": "50", "offset": "0"},
|
|
51
|
+
).mock(
|
|
52
|
+
return_value=httpx.Response(
|
|
53
|
+
200,
|
|
54
|
+
json={
|
|
55
|
+
"data": [
|
|
56
|
+
{
|
|
57
|
+
"id": "ev-1",
|
|
58
|
+
"agent_id": "agent-1",
|
|
59
|
+
"event_type": "tx.completed",
|
|
60
|
+
"score_delta": 1,
|
|
61
|
+
"amount": 5000,
|
|
62
|
+
"currency": "BTC",
|
|
63
|
+
"rail": "lightning",
|
|
64
|
+
"status": "complete",
|
|
65
|
+
"reason": "",
|
|
66
|
+
"metadata": "{}",
|
|
67
|
+
"created_at": "2026-02-27T00:00:00Z",
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
"total": 1,
|
|
71
|
+
"limit": 50,
|
|
72
|
+
"offset": 0,
|
|
73
|
+
"has_more": False,
|
|
74
|
+
"truncated": False,
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
client = BoBClient(api_key="bok_test", agent_id="agent-1")
|
|
80
|
+
events = client.list_agent_credit_events(limit=50, offset=0)
|
|
81
|
+
client.close()
|
|
82
|
+
|
|
83
|
+
assert route.called
|
|
84
|
+
assert len(events) == 1
|
|
85
|
+
assert events[0].event_type == "tx.completed"
|
|
86
|
+
assert events[0].score_delta == 1
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@respx.mock
|
|
90
|
+
def test_agent_webhook_and_events_methods_parse_response() -> None:
|
|
91
|
+
create_route = respx.post(
|
|
92
|
+
"http://localhost:8080/api/v1/agents/agent-1/webhooks"
|
|
93
|
+
).mock(
|
|
94
|
+
return_value=httpx.Response(
|
|
95
|
+
201,
|
|
96
|
+
json={
|
|
97
|
+
"id": "wh-1",
|
|
98
|
+
"operator_id": "op-1",
|
|
99
|
+
"agent_id": "agent-1",
|
|
100
|
+
"url": "https://example.com/hook",
|
|
101
|
+
"events": ["payment_intent.complete"],
|
|
102
|
+
"secret": "sec_abc",
|
|
103
|
+
"active": True,
|
|
104
|
+
"failure_count": 0,
|
|
105
|
+
"created_at": "2026-03-01T00:00:00Z",
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
list_route = respx.get(
|
|
110
|
+
"http://localhost:8080/api/v1/agents/agent-1/webhooks"
|
|
111
|
+
).mock(
|
|
112
|
+
return_value=httpx.Response(
|
|
113
|
+
200,
|
|
114
|
+
json=[
|
|
115
|
+
{
|
|
116
|
+
"id": "wh-1",
|
|
117
|
+
"operator_id": "op-1",
|
|
118
|
+
"agent_id": "agent-1",
|
|
119
|
+
"url": "https://example.com/hook",
|
|
120
|
+
"events": ["payment_intent.complete"],
|
|
121
|
+
"secret": "sec_abc",
|
|
122
|
+
"active": True,
|
|
123
|
+
"failure_count": 0,
|
|
124
|
+
"created_at": "2026-03-01T00:00:00Z",
|
|
125
|
+
}
|
|
126
|
+
],
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
events_route = respx.get(
|
|
130
|
+
"http://localhost:8080/api/v1/agents/agent-1/events",
|
|
131
|
+
params={"limit": "20", "offset": "5"},
|
|
132
|
+
).mock(
|
|
133
|
+
return_value=httpx.Response(
|
|
134
|
+
200,
|
|
135
|
+
json={
|
|
136
|
+
"data": [
|
|
137
|
+
{
|
|
138
|
+
"id": "evt-1",
|
|
139
|
+
"type": "payment_intent.complete",
|
|
140
|
+
"agent_id": "agent-1",
|
|
141
|
+
"operator_id": "op-1",
|
|
142
|
+
"payload": {"intent_id": "pi-1"},
|
|
143
|
+
"created_at": "2026-03-01T00:00:00Z",
|
|
144
|
+
}
|
|
145
|
+
],
|
|
146
|
+
"total": 1,
|
|
147
|
+
"limit": 20,
|
|
148
|
+
"offset": 5,
|
|
149
|
+
"has_more": False,
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
client = BoBClient(api_key="bok_test", agent_id="agent-1")
|
|
155
|
+
created = client.create_agent_webhook("https://example.com/hook", events=["payment_intent.complete"])
|
|
156
|
+
listed = client.list_agent_webhooks()
|
|
157
|
+
events = client.list_agent_events(limit=20, offset=5)
|
|
158
|
+
client.close()
|
|
159
|
+
|
|
160
|
+
assert create_route.called
|
|
161
|
+
assert list_route.called
|
|
162
|
+
assert events_route.called
|
|
163
|
+
assert created.agent_id == "agent-1"
|
|
164
|
+
assert len(listed) == 1
|
|
165
|
+
assert len(events) == 1
|
|
166
|
+
assert events[0]["type"] == "payment_intent.complete"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@respx.mock
|
|
170
|
+
def test_operator_api_key_lifecycle_methods() -> None:
|
|
171
|
+
create_route = respx.post(
|
|
172
|
+
"http://localhost:8080/api/v1/operators/me/api-keys"
|
|
173
|
+
).mock(
|
|
174
|
+
return_value=httpx.Response(
|
|
175
|
+
201,
|
|
176
|
+
json={
|
|
177
|
+
"api_key": "bok_new_secret",
|
|
178
|
+
"key": {
|
|
179
|
+
"id": "key-1",
|
|
180
|
+
"key_prefix": "bok_new_abc",
|
|
181
|
+
"operator_id": "op-1",
|
|
182
|
+
"role": "operator",
|
|
183
|
+
"scopes": ["operators:treasury:write"],
|
|
184
|
+
"name": "deploy key",
|
|
185
|
+
"created_at": "2026-02-27T00:00:00Z",
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
list_route = respx.get(
|
|
191
|
+
"http://localhost:8080/api/v1/operators/me/api-keys"
|
|
192
|
+
).mock(
|
|
193
|
+
return_value=httpx.Response(
|
|
194
|
+
200,
|
|
195
|
+
json=[
|
|
196
|
+
{
|
|
197
|
+
"id": "key-1",
|
|
198
|
+
"key_prefix": "bok_new_abc",
|
|
199
|
+
"operator_id": "op-1",
|
|
200
|
+
"role": "operator",
|
|
201
|
+
"scopes": ["operators:treasury:write"],
|
|
202
|
+
"name": "deploy key",
|
|
203
|
+
"created_at": "2026-02-27T00:00:00Z",
|
|
204
|
+
}
|
|
205
|
+
],
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
revoke_route = respx.post(
|
|
209
|
+
"http://localhost:8080/api/v1/operators/me/api-keys/key-1/revoke"
|
|
210
|
+
).mock(
|
|
211
|
+
return_value=httpx.Response(
|
|
212
|
+
200,
|
|
213
|
+
json={"status": "revoked", "id": "key-1"},
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
client = BoBClient(api_key="bok_test")
|
|
218
|
+
issued = client.create_api_key(name="deploy key", scopes=["operators:treasury:write"])
|
|
219
|
+
listed = client.list_api_keys(limit=10)
|
|
220
|
+
revoked = client.revoke_api_key("key-1")
|
|
221
|
+
client.close()
|
|
222
|
+
|
|
223
|
+
assert create_route.called
|
|
224
|
+
assert list_route.called
|
|
225
|
+
assert revoke_route.called
|
|
226
|
+
assert issued["api_key"] == "bok_new_secret"
|
|
227
|
+
assert issued["key"]["id"] == "key-1"
|
|
228
|
+
assert len(listed) == 1
|
|
229
|
+
assert listed[0].id == "key-1"
|
|
230
|
+
assert revoked["status"] == "revoked"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_agent_calls_require_agent_id() -> None:
|
|
234
|
+
client = BoBClient(api_key="bok_test")
|
|
235
|
+
with pytest.raises(ValueError, match="agent_id is required"):
|
|
236
|
+
client.get_agent_credit()
|
|
237
|
+
client.close()
|