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.
@@ -0,0 +1,16 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+
7
+ # Build
8
+ bin/
9
+ dist/
10
+ sdk/typescript/dist-test/
11
+ sdk/typescript/node_modules/
12
+
13
+ # OS / Editor
14
+ .DS_Store
15
+ .vscode/
16
+ .idea/
@@ -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()