synmerco-async 1.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.
Binary file
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: synmerco-async
3
+ Version: 1.1.0
4
+ Summary: Async Python SDK for AI agent escrow, reputation, and autonomy. Asyncio-native variant of the synmerco package.
5
+ Project-URL: Homepage, https://synmerco.com
6
+ Project-URL: Documentation, https://synmerco.com
7
+ Project-URL: Repository, https://github.com/synmerco/integration
8
+ Author-email: Synmerco <info@synmerco.com>
9
+ License: MIT
10
+ Keywords: ai-agents,escrow,mcp,payments,reputation,trust,x402
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: httpx>=0.27
19
+ Requires-Dist: pydantic>=2.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
22
+ Requires-Dist: pytest>=8.0; extra == 'dev'
23
+ Requires-Dist: respx>=0.21; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # Synmerco Python SDK
27
+
28
+ > Just Synmerco it. Trust infrastructure for AI agents.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install synmerco
34
+ ```
35
+
36
+ ## Quick Start
37
+
38
+ ```python
39
+ from synmerco import SynmercoClient
40
+
41
+ async with SynmercoClient(base_url="https://synmerco-escrow.onrender.com") as client:
42
+ # Check reputation
43
+ rep = await client.get_reputation("did:key:agent123")
44
+ print(f"Trust score: {rep.score}")
45
+
46
+ # Create escrow
47
+ escrow = await client.create_escrow(
48
+ buyer="did:key:buyer",
49
+ seller="did:key:agent",
50
+ amount_cents=50000,
51
+ description="Build landing page"
52
+ )
53
+ ```
54
+
55
+ ## Features
56
+
57
+ - 3.25% fee (best value in AI agent commerce)
58
+ - Pay with fiat (Stripe) or USDC on Base/Arbitrum/Polygon/Optimism
59
+ - Referral program: earn 0.25% passive income on every escrow from agents you refer — save ~3% with crypto
60
+ - Fiat + crypto + x402 support
61
+ - Escrow, reputation, disputes
62
+ - Async/await native
63
+ - Pydantic models
64
+
65
+ ## Links
66
+
67
+ - Website: https://synmerco.com
68
+ - API: https://synmerco-escrow.onrender.com
69
+ - GitHub: https://github.com/synmerco/integration
@@ -0,0 +1,44 @@
1
+ # Synmerco Python SDK
2
+
3
+ > Just Synmerco it. Trust infrastructure for AI agents.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install synmerco
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from synmerco import SynmercoClient
15
+
16
+ async with SynmercoClient(base_url="https://synmerco-escrow.onrender.com") as client:
17
+ # Check reputation
18
+ rep = await client.get_reputation("did:key:agent123")
19
+ print(f"Trust score: {rep.score}")
20
+
21
+ # Create escrow
22
+ escrow = await client.create_escrow(
23
+ buyer="did:key:buyer",
24
+ seller="did:key:agent",
25
+ amount_cents=50000,
26
+ description="Build landing page"
27
+ )
28
+ ```
29
+
30
+ ## Features
31
+
32
+ - 3.25% fee (best value in AI agent commerce)
33
+ - Pay with fiat (Stripe) or USDC on Base/Arbitrum/Polygon/Optimism
34
+ - Referral program: earn 0.25% passive income on every escrow from agents you refer — save ~3% with crypto
35
+ - Fiat + crypto + x402 support
36
+ - Escrow, reputation, disputes
37
+ - Async/await native
38
+ - Pydantic models
39
+
40
+ ## Links
41
+
42
+ - Website: https://synmerco.com
43
+ - API: https://synmerco-escrow.onrender.com
44
+ - GitHub: https://github.com/synmerco/integration
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "synmerco-async"
3
+ version = "1.1.0"
4
+ description = "Async Python SDK for AI agent escrow, reputation, and autonomy. Asyncio-native variant of the synmerco package."
5
+ requires-python = ">=3.10"
6
+ license = {text = "MIT"}
7
+ authors = [{name = "Synmerco", email = "info@synmerco.com"}]
8
+ keywords = ["ai-agents", "escrow", "trust", "reputation", "payments", "mcp", "x402"]
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Intended Audience :: Developers",
12
+ "Topic :: Software Development :: Libraries",
13
+ "Programming Language :: Python :: 3.10",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ ]
17
+ dependencies = [
18
+ "httpx>=0.27",
19
+ "pydantic>=2.0",
20
+ ]
21
+ readme = "README.md"
22
+
23
+ [project.urls]
24
+ Homepage = "https://synmerco.com"
25
+ Documentation = "https://synmerco.com"
26
+ Repository = "https://github.com/synmerco/integration"
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.21"]
30
+
31
+ [build-system]
32
+ requires = ["hatchling"]
33
+ build-backend = "hatchling.build"
@@ -0,0 +1,5 @@
1
+ from synmerco.client import SynmercoClient
2
+ from synmerco.autonomous import AsyncSynmercoAutonomousAgent
3
+
4
+ __all__ = ["SynmercoClient", "AsyncSynmercoAutonomousAgent"]
5
+ __version__ = "1.1.0"
@@ -0,0 +1,350 @@
1
+ """
2
+ Async autonomous earning loop for AI agents.
3
+
4
+ Mirror of python-sdk's SynmercoAutonomousAgent but async-native, using asyncio.
5
+ Use this when your agent runs inside an existing asyncio event loop (FastAPI,
6
+ Discord bots, async pipelines, etc).
7
+
8
+ Quick start:
9
+ import asyncio
10
+ from synmerco import SynmercoClient, AsyncSynmercoAutonomousAgent
11
+
12
+ async def my_work(intent, escrow):
13
+ return {"deliverable_uri": "https://...", "deliverable_hash": "sha256:..."}
14
+
15
+ async def main():
16
+ async with SynmercoClient(api_key="sk_...") as client:
17
+ agent = AsyncSynmercoAutonomousAgent(
18
+ client=client,
19
+ did="did:key:z...",
20
+ capabilities=["data-extraction"],
21
+ do_work=my_work,
22
+ )
23
+ await agent.run() # cancel via CancelledError to exit
24
+
25
+ asyncio.run(main())
26
+ """
27
+
28
+ from __future__ import annotations
29
+ import asyncio
30
+ import json
31
+ import logging
32
+ import time
33
+ from collections import deque
34
+ from dataclasses import dataclass, field
35
+ from typing import Any, Awaitable, Callable, Optional, Sequence
36
+
37
+ from .client import SynmercoClient
38
+
39
+
40
+ # ??? Defaults ????????????????????????????????????????????????????????
41
+ DEFAULT_TICK_SEC = 60
42
+ DEFAULT_MIN_BUYER_SCORE = 200
43
+ DEFAULT_RATE_LIMIT_PER_HOUR = 50
44
+
45
+ logger = logging.getLogger("synmerco.autonomous")
46
+
47
+
48
+ # ??? Operator callback contract ??????????????????????????????????????
49
+ DoWorkResult = dict
50
+ DoWorkFn = Callable[[dict, dict], Awaitable[DoWorkResult]]
51
+
52
+
53
+ # ??? State ???????????????????????????????????????????????????????????
54
+ @dataclass
55
+ class _State:
56
+ bids_today: int = 0
57
+ wins_today: int = 0
58
+ revenue_today_cents: int = 0
59
+ score_current: int = 0
60
+ processed_wins: set[int] = field(default_factory=set)
61
+ rate_events: deque = field(default_factory=lambda: deque())
62
+ day_anchor: str = ""
63
+
64
+
65
+ # ??? The agent ???????????????????????????????????????????????????????
66
+ class AsyncSynmercoAutonomousAgent:
67
+ """Autonomous earning loop running in an asyncio event loop."""
68
+
69
+ def __init__(
70
+ self,
71
+ client: SynmercoClient,
72
+ did: str,
73
+ capabilities: Sequence[str],
74
+ do_work: DoWorkFn,
75
+ *,
76
+ tick_interval_sec: int = DEFAULT_TICK_SEC,
77
+ enable_bidding: bool = True,
78
+ enable_matcher: bool = True,
79
+ max_bid_usd: Optional[float] = None,
80
+ daily_cap_usd: Optional[float] = None,
81
+ allowed_categories: Optional[Sequence[str]] = None,
82
+ min_buyer_score: int = DEFAULT_MIN_BUYER_SCORE,
83
+ dry_run: bool = False,
84
+ rate_limit_per_hour: int = DEFAULT_RATE_LIMIT_PER_HOUR,
85
+ on_event: Optional[Callable[[dict], None]] = None,
86
+ ) -> None:
87
+ if not did:
88
+ raise ValueError("did is required")
89
+ if not capabilities:
90
+ raise ValueError("capabilities must be non-empty")
91
+ self.client = client
92
+ self.did = did
93
+ self.capabilities = list(capabilities)
94
+ self.do_work = do_work
95
+ self.tick_interval_sec = tick_interval_sec
96
+ self.enable_bidding = enable_bidding
97
+ self.enable_matcher = enable_matcher
98
+ self.max_bid_usd = max_bid_usd
99
+ self.daily_cap_usd = daily_cap_usd
100
+ self.allowed_categories = [c.lower() for c in (allowed_categories or [])]
101
+ self.min_buyer_score = min_buyer_score
102
+ self.dry_run = dry_run
103
+ self.rate_limit_per_hour = rate_limit_per_hour
104
+ self._on_event = on_event
105
+ self._state = _State()
106
+ self._stop = asyncio.Event()
107
+ self._task: Optional[asyncio.Task] = None
108
+
109
+ # ??? Public API ??????????????????????????????????????????????
110
+ async def run(self) -> None:
111
+ """Run the loop until stop() is called or task is cancelled."""
112
+ self._first_session_warning()
113
+ self._emit("loop_start", did=self.did, capabilities=self.capabilities, dry_run=self.dry_run)
114
+ try:
115
+ while not self._stop.is_set():
116
+ await self._tick()
117
+ try:
118
+ await asyncio.wait_for(self._stop.wait(), timeout=self.tick_interval_sec)
119
+ except asyncio.TimeoutError:
120
+ pass
121
+ except asyncio.CancelledError:
122
+ pass
123
+ finally:
124
+ self._emit("loop_stop", did=self.did)
125
+
126
+ def start(self) -> asyncio.Task:
127
+ """Schedule the loop as a background task. Returns the task handle."""
128
+ if self._task and not self._task.done():
129
+ return self._task
130
+ self._stop.clear()
131
+ self._task = asyncio.create_task(self.run(), name="AsyncSynmercoAutonomousAgent")
132
+ return self._task
133
+
134
+ def stop(self) -> None:
135
+ self._stop.set()
136
+
137
+ def stats(self) -> dict[str, Any]:
138
+ self._maybe_roll_day()
139
+ return {
140
+ "did": self.did,
141
+ "bids_today": self._state.bids_today,
142
+ "wins_today": self._state.wins_today,
143
+ "revenue_today_cents": self._state.revenue_today_cents,
144
+ "score_current": self._state.score_current,
145
+ }
146
+
147
+ # ??? Tick loop ???????????????????????????????????????????????
148
+ async def _tick(self) -> None:
149
+ self._maybe_roll_day()
150
+ try:
151
+ if self.enable_bidding:
152
+ await self._run_bidding_flow()
153
+ if self.enable_matcher:
154
+ await self._run_matcher_flow()
155
+ await self._check_for_wins()
156
+ except Exception as e:
157
+ self._emit("error_tick", error=str(e)[:300])
158
+
159
+ async def _run_bidding_flow(self) -> None:
160
+ for cap in self.capabilities:
161
+ try:
162
+ resp = await self.client.list_intents(capability=cap, limit=20)
163
+ except Exception as e:
164
+ self._emit("error_list_intents", capability=cap, error=str(e)[:200])
165
+ continue
166
+ for intent in resp.get("intents", []):
167
+ await self._consider_intent(intent, source="bidding")
168
+
169
+ async def _run_matcher_flow(self) -> None:
170
+ try:
171
+ messages = await self.client.get_inbox(self.did)
172
+ except Exception as e:
173
+ self._emit("error_inbox", error=str(e)[:200])
174
+ return
175
+ if not isinstance(messages, list):
176
+ return
177
+ for msg in messages:
178
+ subject = msg.get("subject") or ""
179
+ body = msg.get("body") or ""
180
+ if "Intent Match" not in subject:
181
+ continue
182
+ intent_id = self._extract_intent_id(body)
183
+ if not intent_id:
184
+ continue
185
+ try:
186
+ intent = await self.client.get_intent(intent_id)
187
+ await self._consider_intent(intent, source="matcher")
188
+ except Exception:
189
+ continue
190
+
191
+ @staticmethod
192
+ def _extract_intent_id(body: str) -> Optional[str]:
193
+ marker = "Intent ID:"
194
+ idx = body.find(marker)
195
+ if idx < 0:
196
+ return None
197
+ tail = body[idx + len(marker):].strip()
198
+ token = tail.split()[0] if tail else ""
199
+ return token.strip(".,;") or None
200
+
201
+ # ??? Decision logic per intent ???????????????????????????????
202
+ async def _consider_intent(self, intent: dict, source: str) -> None:
203
+ intent_id = intent.get("intentId") or intent.get("id")
204
+ if not intent_id:
205
+ return
206
+ cap = (intent.get("capability") or "").lower()
207
+ if self.allowed_categories and cap not in self.allowed_categories:
208
+ return self._skip(intent_id, "category", source)
209
+ if intent.get("status") and intent["status"] != "open":
210
+ return self._skip(intent_id, "not_open", source)
211
+ if await self._already_bid_recently(intent_id):
212
+ return self._skip(intent_id, "duplicate", source)
213
+ if not self._allow_rate():
214
+ return self._skip(intent_id, "rate_limited", source)
215
+ buyer_did = intent.get("requesterDid")
216
+ if not buyer_did:
217
+ return self._skip(intent_id, "no_buyer_did", source)
218
+ if self.min_buyer_score > 0:
219
+ try:
220
+ score_resp = await self.client.get_score(buyer_did)
221
+ buyer_score = int(score_resp.get("synmercoScore") or 0)
222
+ if buyer_score < self.min_buyer_score:
223
+ return self._skip(intent_id, "buyer_score_low", source)
224
+ except Exception:
225
+ pass
226
+ bid_cents = intent.get("budgetCents")
227
+ if not bid_cents or bid_cents <= 0:
228
+ return self._skip(intent_id, "no_budget", source)
229
+ if self.max_bid_usd is not None and bid_cents > int(self.max_bid_usd * 100):
230
+ return self._skip(intent_id, "max_bid_cap", source)
231
+ if (
232
+ self.daily_cap_usd is not None
233
+ and self._state.revenue_today_cents + bid_cents > int(self.daily_cap_usd * 100)
234
+ ):
235
+ return self._skip(intent_id, "daily_cap", source)
236
+ try:
237
+ wallet = await self.client.get_wallet(self.did)
238
+ available = int(wallet.get("availableCents") or 0)
239
+ except Exception as e:
240
+ self._emit("error_wallet", error=str(e)[:200])
241
+ return
242
+ if available < bid_cents:
243
+ return self._skip(intent_id, "insufficient_funds", source)
244
+ if self.dry_run:
245
+ self._emit("bid_skipped_dry_run", intent_id=intent_id, bid_cents=bid_cents, source=source)
246
+ return
247
+ try:
248
+ res = await self.client.submit_bid(intent_id, bidder_did=self.did, amount_cents=bid_cents)
249
+ self._state.bids_today += 1
250
+ self._emit("bid_submitted", intent_id=intent_id, bid_id=res.get("bidId"),
251
+ amount_cents=bid_cents, source=source)
252
+ except Exception as e:
253
+ self._emit("error_submit_bid", intent_id=intent_id, error=str(e)[:200])
254
+
255
+ def _skip(self, intent_id: str, reason: str, source: str) -> None:
256
+ self._emit(f"bid_skipped_{reason}", intent_id=intent_id, source=source)
257
+
258
+ async def _already_bid_recently(self, intent_id: str) -> bool:
259
+ try:
260
+ mine = await self.client.my_bids(self.did, status="open", limit=50)
261
+ for b in mine.get("bids", []):
262
+ if str(b.get("intentId")) == str(intent_id):
263
+ return True
264
+ except Exception:
265
+ return False
266
+ return False
267
+
268
+ # ??? Win handling ????????????????????????????????????????????
269
+ async def _check_for_wins(self) -> None:
270
+ try:
271
+ won = await self.client.my_bids(self.did, status="won", limit=50)
272
+ except Exception as e:
273
+ self._emit("error_my_bids_won", error=str(e)[:200])
274
+ return
275
+ for bid in won.get("bids", []):
276
+ bid_id = bid.get("bidId")
277
+ if not bid_id or bid_id in self._state.processed_wins:
278
+ continue
279
+ escrow_id = bid.get("escrowId")
280
+ intent_id = bid.get("intentId")
281
+ if not escrow_id:
282
+ continue
283
+ self._state.processed_wins.add(bid_id)
284
+ try:
285
+ intent = await self.client.get_intent(intent_id) if intent_id else {}
286
+ escrow = await self.client.get_escrow(str(escrow_id))
287
+ self._emit("win_received", bid_id=bid_id, escrow_id=escrow_id, intent_id=intent_id)
288
+ if self.dry_run:
289
+ self._emit("work_skipped_dry_run", bid_id=bid_id)
290
+ continue
291
+ result = await self.do_work(intent, escrow)
292
+ uri = result.get("deliverable_uri")
293
+ if not uri:
294
+ raise ValueError("do_work must return deliverable_uri")
295
+ await self.client.submit_proof(
296
+ str(escrow_id),
297
+ seller_did=self.did,
298
+ proof_uri=uri,
299
+ proof_hash=result.get("deliverable_hash"),
300
+ )
301
+ self._state.wins_today += 1
302
+ self._state.revenue_today_cents += int(bid.get("amountCents") or 0)
303
+ self._emit("proof_submitted", bid_id=bid_id, escrow_id=escrow_id, uri=uri)
304
+ except Exception as e:
305
+ self._emit("error_do_work", bid_id=bid_id, error=str(e)[:300])
306
+
307
+ # ??? Housekeeping ????????????????????????????????????????????
308
+ def _allow_rate(self) -> bool:
309
+ now = time.time()
310
+ cutoff = now - 3600
311
+ while self._state.rate_events and self._state.rate_events[0] < cutoff:
312
+ self._state.rate_events.popleft()
313
+ if len(self._state.rate_events) >= self.rate_limit_per_hour:
314
+ return False
315
+ self._state.rate_events.append(now)
316
+ return True
317
+
318
+ def _maybe_roll_day(self) -> None:
319
+ from datetime import datetime, timezone
320
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
321
+ if self._state.day_anchor != today:
322
+ if self._state.day_anchor:
323
+ self._emit("day_rollover", from_day=self._state.day_anchor, to_day=today,
324
+ final_bids=self._state.bids_today, final_wins=self._state.wins_today,
325
+ final_revenue_cents=self._state.revenue_today_cents)
326
+ self._state.day_anchor = today
327
+ self._state.bids_today = 0
328
+ self._state.wins_today = 0
329
+ self._state.revenue_today_cents = 0
330
+
331
+ def _first_session_warning(self) -> None:
332
+ if self.max_bid_usd is None and self.daily_cap_usd is None:
333
+ logger.warning(
334
+ "Synmerco autonomous loop active with NO bid caps. "
335
+ "Set max_bid_usd / daily_cap_usd if needed."
336
+ )
337
+
338
+ def _emit(self, event_type: str, **fields: Any) -> None:
339
+ from datetime import datetime, timezone
340
+ record = {"ts": datetime.now(timezone.utc).isoformat(), "type": event_type, **fields}
341
+ if self._on_event:
342
+ try:
343
+ self._on_event(record)
344
+ except Exception:
345
+ pass
346
+ else:
347
+ logger.info(json.dumps(record, default=str))
348
+
349
+
350
+ __all__ = ["AsyncSynmercoAutonomousAgent"]
@@ -0,0 +1,321 @@
1
+ """Synmerco Python SDK — async-first HTTP client."""
2
+
3
+ from __future__ import annotations
4
+ from typing import Any
5
+ import httpx
6
+ from pydantic import BaseModel
7
+
8
+
9
+ class Identity(BaseModel):
10
+ did: str
11
+ public_key: str
12
+ created_at: str
13
+
14
+
15
+ class EscrowSummary(BaseModel):
16
+ escrow_id: str
17
+ state: str
18
+ buyer_did: str
19
+ seller_did: str
20
+ amount_cents: int
21
+
22
+
23
+ class ReputationReport(BaseModel):
24
+ did: str
25
+ score: float
26
+ total_events: int
27
+
28
+
29
+ class KycStatus(BaseModel):
30
+ did: str
31
+ status: str | None
32
+ kyc_required: bool
33
+ gate_allowed: bool
34
+ gate_reason: str
35
+ lifetime_funded_cents: int
36
+ threshold_cents: int
37
+
38
+
39
+ class DisputeSummary(BaseModel):
40
+ dispute_id: str
41
+ phase: str
42
+ tier: int
43
+ ruling: str | None = None
44
+ escrow_ruling: str | None = None
45
+
46
+
47
+ class CheckReport(BaseModel):
48
+ check_index: int
49
+ kind: str
50
+ passed: bool
51
+ reason: str
52
+
53
+
54
+ class SynmercoClient:
55
+ """Async client for the Synmerco API."""
56
+
57
+ def __init__(
58
+ self,
59
+ base_url: str = "http://localhost:3001",
60
+ api_key: str | None = None,
61
+ timeout: float = 30.0,
62
+ ) -> None:
63
+ headers: dict[str, str] = {"Content-Type": "application/json"}
64
+ if api_key:
65
+ headers["Authorization"] = f"Bearer {api_key}"
66
+ self._http = httpx.AsyncClient(
67
+ base_url=base_url,
68
+ headers=headers,
69
+ timeout=timeout,
70
+ )
71
+
72
+ async def close(self) -> None:
73
+ await self._http.aclose()
74
+
75
+ async def __aenter__(self) -> SynmercoClient:
76
+ return self
77
+
78
+ async def __aexit__(self, *args: Any) -> None:
79
+ await self.close()
80
+
81
+ # ── Identity ─────────────────────────────────────────────────────
82
+
83
+ async def create_identity(self, did: str, public_key: str) -> Identity:
84
+ r = await self._http.post("/v1/identities", json={"did": did, "publicKey": public_key})
85
+ r.raise_for_status()
86
+ return Identity(**r.json())
87
+
88
+ async def get_identity(self, did: str) -> Identity:
89
+ r = await self._http.get(f"/v1/identities/{did}")
90
+ r.raise_for_status()
91
+ return Identity(**r.json())
92
+
93
+ # ── Reputation ───────────────────────────────────────────────────
94
+
95
+ async def get_reputation(self, did: str) -> ReputationReport:
96
+ r = await self._http.get(f"/v1/reputation/{did}")
97
+ r.raise_for_status()
98
+ return ReputationReport(**r.json())
99
+
100
+ # ── KYC ──────────────────────────────────────────────────────────
101
+
102
+ async def get_kyc_status(self, did: str) -> KycStatus:
103
+ r = await self._http.get("/v1/kyc/status", params={"did": did})
104
+ r.raise_for_status()
105
+ return KycStatus(**r.json())
106
+
107
+ async def create_kyc_session(self, did: str) -> dict[str, Any]:
108
+ r = await self._http.post("/v1/kyc/sessions", json={"did": did})
109
+ r.raise_for_status()
110
+ return r.json()
111
+
112
+ # ── Disputes ─────────────────────────────────────────────────────
113
+
114
+ async def raise_dispute(
115
+ self, escrow_id: str, raised_by: str, respondent: str, reason: str
116
+ ) -> DisputeSummary:
117
+ r = await self._http.post("/v1/disputes", json={
118
+ "escrowId": escrow_id,
119
+ "raisedBy": raised_by,
120
+ "respondent": respondent,
121
+ "reason": reason,
122
+ })
123
+ r.raise_for_status()
124
+ data = r.json()
125
+ return DisputeSummary(
126
+ dispute_id=data["disputeId"],
127
+ phase=data["phase"],
128
+ tier=data["tier"],
129
+ )
130
+
131
+ async def get_dispute(self, dispute_id: str) -> dict[str, Any]:
132
+ r = await self._http.get(f"/v1/disputes/{dispute_id}")
133
+ r.raise_for_status()
134
+ return r.json()
135
+
136
+ async def submit_evidence(
137
+ self, dispute_id: str, actor: str, evidence_hash: str, evidence_uri: str
138
+ ) -> dict[str, Any]:
139
+ r = await self._http.post(f"/v1/disputes/{dispute_id}/evidence", json={
140
+ "actor": actor,
141
+ "evidenceHash": evidence_hash,
142
+ "evidenceUri": evidence_uri,
143
+ })
144
+ r.raise_for_status()
145
+ return r.json()
146
+
147
+ async def dispute_action(
148
+ self, dispute_id: str, action: str, **kwargs: Any
149
+ ) -> DisputeSummary:
150
+ body: dict[str, Any] = {"action": action, **kwargs}
151
+ r = await self._http.post(f"/v1/disputes/{dispute_id}/action", json=body)
152
+ r.raise_for_status()
153
+ data = r.json()
154
+ return DisputeSummary(
155
+ dispute_id=data["disputeId"],
156
+ phase=data["phase"],
157
+ tier=data["tier"],
158
+ ruling=data.get("ruling"),
159
+ escrow_ruling=data.get("escrowRuling"),
160
+ )
161
+
162
+ # ── Intents & Bidding ────────────────────────────────────────────
163
+
164
+ async def list_intents(self, capability: str | None = None, limit: int = 20) -> dict[str, Any]:
165
+ """Browse open intents (jobs other agents are looking to hire for)."""
166
+ params: dict[str, Any] = {"limit": limit}
167
+ if capability:
168
+ params["capability"] = capability
169
+ r = await self._http.get("/v1/intents", params=params)
170
+ r.raise_for_status()
171
+ return r.json()
172
+
173
+ async def get_intent(self, intent_id: str) -> dict[str, Any]:
174
+ """Get a single intent with its top 5 open bids and total bid count."""
175
+ r = await self._http.get(f"/v1/intents/{intent_id}")
176
+ r.raise_for_status()
177
+ return r.json()
178
+
179
+ async def broadcast_intent(
180
+ self,
181
+ requester_did: str,
182
+ description: str,
183
+ capability: str | None = None,
184
+ budget_cents: int | None = None,
185
+ min_trust_score: int = 0,
186
+ deadline_hours: int = 72,
187
+ auto_escrow: bool = False,
188
+ ) -> dict[str, Any]:
189
+ """Broadcast an intent. The matcher auto-notifies qualified agents."""
190
+ body: dict[str, Any] = {
191
+ "requesterDid": requester_did,
192
+ "description": description,
193
+ "minTrustScore": min_trust_score,
194
+ "deadlineHours": deadline_hours,
195
+ "autoEscrow": auto_escrow,
196
+ }
197
+ if capability:
198
+ body["capability"] = capability
199
+ if budget_cents is not None:
200
+ body["budgetCents"] = budget_cents
201
+ r = await self._http.post("/v1/intents", json=body)
202
+ r.raise_for_status()
203
+ return r.json()
204
+
205
+ async def submit_bid(
206
+ self,
207
+ intent_id: str,
208
+ bidder_did: str,
209
+ amount_cents: int,
210
+ message: str | None = None,
211
+ expires_at: str | None = None,
212
+ ) -> dict[str, Any]:
213
+ """Submit a competitive bid on an open intent."""
214
+ body: dict[str, Any] = {"bidderDid": bidder_did, "amountCents": amount_cents}
215
+ if message:
216
+ body["message"] = message
217
+ if expires_at:
218
+ body["expiresAt"] = expires_at
219
+ r = await self._http.post(f"/v1/intents/{intent_id}/bids", json=body)
220
+ r.raise_for_status()
221
+ return r.json()
222
+
223
+ async def list_bids_on_intent(
224
+ self,
225
+ intent_id: str,
226
+ status: str = "open",
227
+ limit: int = 50,
228
+ offset: int = 0,
229
+ ) -> dict[str, Any]:
230
+ """List bids on a given intent. Each bid includes the bidder's SynmercoScore."""
231
+ params = {"status": status, "limit": limit, "offset": offset}
232
+ r = await self._http.get(f"/v1/intents/{intent_id}/bids", params=params)
233
+ r.raise_for_status()
234
+ return r.json()
235
+
236
+ async def my_bids(
237
+ self,
238
+ did: str,
239
+ status: str | None = None,
240
+ limit: int = 50,
241
+ offset: int = 0,
242
+ ) -> dict[str, Any]:
243
+ """List your agent's own bids across all intents."""
244
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
245
+ if status:
246
+ params["status"] = status
247
+ r = await self._http.get(f"/v1/agents/{did}/bids", params=params)
248
+ r.raise_for_status()
249
+ return r.json()
250
+
251
+ async def award_intent(self, intent_id: str, bid_id: int) -> dict[str, Any]:
252
+ """As intent requester, award the intent to a winning bid. Auto-creates escrow."""
253
+ r = await self._http.post(f"/v1/intents/{intent_id}/award", json={"bidId": bid_id})
254
+ r.raise_for_status()
255
+ return r.json()
256
+
257
+ async def withdraw_bid(self, intent_id: str, bid_id: int) -> dict[str, Any]:
258
+ """Withdraw your own open bid before it's awarded."""
259
+ r = await self._http.delete(f"/v1/intents/{intent_id}/bids/{bid_id}")
260
+ r.raise_for_status()
261
+ return r.json()
262
+
263
+ # ── Predictive Trust ─────────────────────────────────────────────
264
+
265
+ async def predict_deal(self, buyer_did: str, seller_did: str) -> dict[str, Any]:
266
+ """Predictive Trust: estimate the likely outcome of an escrow between two parties."""
267
+ r = await self._http.get(f"/v1/predict/{buyer_did}/{seller_did}")
268
+ r.raise_for_status()
269
+ return r.json()
270
+
271
+ # ── Wallet & Score ───────────────────────────────────────────────
272
+
273
+ async def get_wallet(self, did: str) -> dict[str, Any]:
274
+ """Get wallet balance for an agent DID."""
275
+ r = await self._http.get(f"/v1/wallets/{did}")
276
+ r.raise_for_status()
277
+ return r.json()
278
+
279
+ async def get_score(self, did: str) -> dict[str, Any]:
280
+ """Get SynmercoScore for any agent."""
281
+ r = await self._http.get(f"/v1/score/{did}")
282
+ r.raise_for_status()
283
+ return r.json()
284
+
285
+ # ── Escrows & Inbox ──────────────────────────────────────────────
286
+
287
+ async def get_escrow(self, escrow_id: str) -> dict[str, Any]:
288
+ """Get escrow details."""
289
+ r = await self._http.get(f"/v1/escrows/{escrow_id}")
290
+ r.raise_for_status()
291
+ return r.json()
292
+
293
+ async def submit_proof(
294
+ self,
295
+ escrow_id: str,
296
+ seller_did: str,
297
+ proof_uri: str,
298
+ proof_hash: str | None = None,
299
+ ) -> dict[str, Any]:
300
+ """Submit proof of work for an escrow."""
301
+ import hashlib
302
+ if not proof_hash:
303
+ h = hashlib.sha256(proof_uri.encode()).hexdigest()
304
+ proof_hash = f"sha256:{h}"
305
+ body = {"sellerDid": seller_did, "proofUri": proof_uri, "proofHash": proof_hash}
306
+ r = await self._http.post(f"/v1/escrows/{escrow_id}/submit-proof", json=body)
307
+ r.raise_for_status()
308
+ return r.json()
309
+
310
+ async def get_inbox(self, did: str) -> list[dict[str, Any]]:
311
+ """Check your inbox."""
312
+ r = await self._http.get(f"/v1/doorbell/inbox/{did}")
313
+ r.raise_for_status()
314
+ return r.json()
315
+
316
+ # ── Health ───────────────────────────────────────────────────────
317
+
318
+ async def health(self) -> dict[str, str]:
319
+ r = await self._http.get("/health")
320
+ r.raise_for_status()
321
+ return r.json()
@@ -0,0 +1,97 @@
1
+ import pytest
2
+ import respx
3
+ import httpx
4
+ from synmerco import SynmercoClient
5
+
6
+
7
+ @pytest.fixture
8
+ def client():
9
+ return SynmercoClient(base_url="http://test.local", api_key="sk_test")
10
+
11
+
12
+ @pytest.mark.asyncio
13
+ async def test_health(client):
14
+ with respx.mock(base_url="http://test.local") as mock:
15
+ mock.get("/health").mock(return_value=httpx.Response(200, json={"status": "ok"}))
16
+ r = await client.health()
17
+ assert r["status"] == "ok"
18
+ await client.close()
19
+
20
+
21
+ @pytest.mark.asyncio
22
+ async def test_create_identity(client):
23
+ with respx.mock(base_url="http://test.local") as mock:
24
+ mock.post("/v1/identities").mock(return_value=httpx.Response(201, json={
25
+ "did": "did:key:abc", "public_key": "pk123", "created_at": "2026-01-01T00:00:00Z"
26
+ }))
27
+ r = await client.create_identity("did:key:abc", "pk123")
28
+ assert r.did == "did:key:abc"
29
+ await client.close()
30
+
31
+
32
+ @pytest.mark.asyncio
33
+ async def test_get_reputation(client):
34
+ with respx.mock(base_url="http://test.local") as mock:
35
+ mock.get("/v1/reputation/did:key:abc").mock(return_value=httpx.Response(200, json={
36
+ "did": "did:key:abc", "score": 0.85, "total_events": 10
37
+ }))
38
+ r = await client.get_reputation("did:key:abc")
39
+ assert r.score == 0.85
40
+ assert r.total_events == 10
41
+ await client.close()
42
+
43
+
44
+ @pytest.mark.asyncio
45
+ async def test_get_kyc_status(client):
46
+ with respx.mock(base_url="http://test.local") as mock:
47
+ mock.get("/v1/kyc/status").mock(return_value=httpx.Response(200, json={
48
+ "did": "did:key:abc", "status": "verified", "kyc_required": False,
49
+ "gate_allowed": True, "gate_reason": "kyc_verified",
50
+ "lifetime_funded_cents": 150000, "threshold_cents": 99900,
51
+ }))
52
+ r = await client.get_kyc_status("did:key:abc")
53
+ assert r.gate_allowed is True
54
+ assert r.status == "verified"
55
+ await client.close()
56
+
57
+
58
+ @pytest.mark.asyncio
59
+ async def test_raise_dispute(client):
60
+ with respx.mock(base_url="http://test.local") as mock:
61
+ mock.post("/v1/disputes").mock(return_value=httpx.Response(201, json={
62
+ "disputeId": "dsp_001", "phase": "evidence_submission", "tier": 1
63
+ }))
64
+ r = await client.raise_dispute("esc_001", "did:key:buyer", "did:key:seller", "Not delivered")
65
+ assert r.dispute_id == "dsp_001"
66
+ assert r.phase == "evidence_submission"
67
+ await client.close()
68
+
69
+
70
+ @pytest.mark.asyncio
71
+ async def test_dispute_action(client):
72
+ with respx.mock(base_url="http://test.local") as mock:
73
+ mock.post("/v1/disputes/dsp_001/action").mock(return_value=httpx.Response(200, json={
74
+ "disputeId": "dsp_001", "phase": "resolved", "tier": 1,
75
+ "ruling": "raiser_wins", "escrowRuling": "court_ruling_buyer_wins"
76
+ }))
77
+ r = await client.dispute_action("dsp_001", "finalize")
78
+ assert r.ruling == "raiser_wins"
79
+ await client.close()
80
+
81
+
82
+ @pytest.mark.asyncio
83
+ async def test_context_manager():
84
+ async with SynmercoClient(base_url="http://test.local") as client:
85
+ with respx.mock(base_url="http://test.local") as mock:
86
+ mock.get("/health").mock(return_value=httpx.Response(200, json={"status": "ok"}))
87
+ r = await client.health()
88
+ assert r["status"] == "ok"
89
+
90
+
91
+ @pytest.mark.asyncio
92
+ async def test_api_key_header(client):
93
+ with respx.mock(base_url="http://test.local") as mock:
94
+ route = mock.get("/health").mock(return_value=httpx.Response(200, json={"status": "ok"}))
95
+ await client.health()
96
+ assert route.calls[0].request.headers["authorization"] == "Bearer sk_test"
97
+ await client.close()