aotrust-protocol 2.3.3__tar.gz → 2.3.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aotrust-protocol
3
- Version: 2.3.3
3
+ Version: 2.3.4
4
4
  Summary: Asyncio-native SDK for A&O Trust Layer — Agent Notary Service
5
5
  Author: A&O Trust Layer
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  """A&O Trust Layer — Agent Notary SDK (asyncio-native)."""
2
2
 
3
- __version__ = "2.3.3"
3
+ __version__ = "2.3.4"
4
4
 
5
5
  from .client import NotaryClient
6
6
  from .models import NotarizeRequest, NotarizeResult, PdrResult, StatusResult
@@ -1,4 +1,4 @@
1
- """Asyncio-native NotaryClient for A&O Trust Layer."""
1
+ """Asyncio-native NotaryClient for AOTrust."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -23,9 +23,19 @@ DEFAULT_POLL_TIMEOUT = 120.0
23
23
  MAX_RETRIES = 3
24
24
  RETRY_DELAYS = (1.0, 2.0, 4.0)
25
25
 
26
+ VERIFY_WEB_BASE = "https://verify.aotrust.link"
27
+
26
28
 
27
29
  class NotaryClient:
28
- """Async client for the A&O Trust Layer Notary API."""
30
+ """Async client for the AOTrust Notary API.
31
+
32
+ URL map matches the live API (openapi.yaml v2.3.4+):
33
+ - notarize() -> POST /notarize (anonymous x402 payment path)
34
+ - quote() -> POST /v1/notarize/quote (authenticated CI channel; api_key required)
35
+ - get_status() -> GET /v1/status/{job_id}
36
+ - shield_free()-> POST /v1/shield/free (free tier, rate limited)
37
+ - pubkey lazy-fetch: GET /v1/notary/pubkey (cached)
38
+ """
29
39
 
30
40
  def __init__(
31
41
  self,
@@ -34,8 +44,10 @@ class NotaryClient:
34
44
  notary_pubkey: Optional[str] = None,
35
45
  ):
36
46
  self.api_key = api_key or os.environ.get("NOTARY_API_KEY", "")
37
- self.base_url = (base_url or os.environ.get("NOTARY_API_URL", "https://api.aotrust.link/v1")).rstrip("/")
38
- self.notary_pubkey = notary_pubkey or os.environ.get("NOTARY_PUBKEY", "9c7d64bbabe90ead8f18982497a47acbd95677cef0d9285caec95377e4ccf6b0")
47
+ self.base_url = (base_url or os.environ.get("NOTARY_API_URL", "https://api.aotrust.link")).rstrip("/")
48
+ # No hardcoded fallback: if not provided, fetched lazily from the server (cached).
49
+ self._pubkey_provided = notary_pubkey or os.environ.get("NOTARY_PUBKEY", "") or ""
50
+ self._pubkey_fetched: Optional[str] = None
39
51
 
40
52
  def _headers(self) -> dict:
41
53
  from . import __version__
@@ -47,6 +59,29 @@ class NotaryClient:
47
59
  h["X-API-Key"] = self.api_key
48
60
  return h
49
61
 
62
+ async def get_notary_pubkey(self) -> str:
63
+ """Notary Ed25519 public key (hex, 64 chars).
64
+
65
+ Resolution order:
66
+ 1. explicitly provided (constructor / NOTARY_PUBKEY env)
67
+ 2. lazy fetch GET /v1/notary/pubkey — fetched once, cached
68
+ Raises NotaryError when neither is available.
69
+ """
70
+ if self._pubkey_provided:
71
+ return self._pubkey_provided
72
+ if self._pubkey_fetched:
73
+ return self._pubkey_fetched
74
+ status, body = await self._request("GET", "/v1/notary/pubkey")
75
+ self._check_status(status, body)
76
+ pubkey = body.get("pubkey_hex", body.get("pubkey", body.get("notary_pubkey", "")))
77
+ if not pubkey:
78
+ raise NotaryError(
79
+ "notary_pubkey not provided and not available from server "
80
+ f"(GET /v1/notary/pubkey returned: {body})"
81
+ )
82
+ self._pubkey_fetched = pubkey
83
+ return pubkey
84
+
50
85
  async def _request(
51
86
  self,
52
87
  method: str,
@@ -74,6 +109,9 @@ class NotaryClient:
74
109
  def _check_status(self, status: int, body: dict) -> None:
75
110
  if status == 401:
76
111
  raise NotaryAuthError(body.get("error", "Unauthorized"))
112
+ if status == 402:
113
+ # x402 challenge: surface the full payment requirement to the caller
114
+ raise NotaryPaymentError(f"Payment required: {body}")
77
115
  if status == 403:
78
116
  detail = body.get("detail", body.get("error", "Forbidden"))
79
117
  raise NotaryPaymentError(detail)
@@ -85,14 +123,26 @@ class NotaryClient:
85
123
  raise NotaryServerError(body.get("error", f"Server error {status}"), status_code=status)
86
124
 
87
125
  async def notarize(self, request: NotarizeRequest) -> NotarizeResult:
88
- status, body = await self._request("POST", "/notarize", json_data=request.to_api_payload())
126
+ """POST /notarize — anonymous x402 path.
127
+
128
+ Without payment the server answers 402 with the x402 challenge
129
+ (x402Version/accepts[0].resource=...); NotaryPaymentError carries it.
130
+ Payload built for the live x402 contract: work_hash at top level
131
+ (the CI-channel envelope with tx_hash/payload.work_hash is NOT used here).
132
+ """
133
+ payload: dict = {"work_hash": request.work_hash or ""}
134
+ if request.agent_sig:
135
+ payload["agent_sig"] = request.agent_sig
136
+ if request.agent_pubkey:
137
+ payload["agent_pubkey"] = request.agent_pubkey
138
+ status, body = await self._request("POST", "/notarize", json_data=payload)
89
139
  self._check_status(status, body)
90
140
  if status not in (200, 202):
91
141
  raise NotaryError(f"Unexpected status {status}: {body}")
92
142
  return NotarizeResult(job_id=body.get("job_id", ""))
93
143
 
94
144
  async def get_status(self, job_id: str) -> StatusResult:
95
- status, body = await self._request("GET", f"/status/{job_id}")
145
+ status, body = await self._request("GET", f"/v1/status/{job_id}")
96
146
  self._check_status(status, body)
97
147
  result = body.get("result", {})
98
148
  return StatusResult(
@@ -102,15 +152,28 @@ class NotaryClient:
102
152
  )
103
153
 
104
154
  async def get_pdr(self, job_id: str) -> PdrResult:
105
- status, body = await self._request("GET", f"/pdr/{job_id}")
106
- self._check_status(status, body)
107
- verify_url = f"{self.base_url}/verify/{job_id}" if "verify_url" not in body else body.get("verify_url")
155
+ """Fetch PDR via status endpoint and build the public verify URL."""
156
+ status_result = await self.get_status(job_id)
157
+ pdr_b64 = status_result.pdr_b64 or ""
108
158
  return PdrResult(
109
- pdr_b64=body.get("pdr", body.get("pdr_b64", "")),
110
- payment_hash=body.get("near_tx_hash") or body.get("payment_hash"),
111
- verify_url=verify_url,
159
+ pdr_b64=pdr_b64,
160
+ payment_hash=status_result.payment_hash,
161
+ verify_url=self._verify_url(pdr_b64),
112
162
  )
113
163
 
164
+ async def verify_pdr(self, pdr_b64: str) -> dict:
165
+ """GET /v1/pdr/verify/{pdr_b64} — server-side PDR verification."""
166
+ from urllib.parse import quote as _urlquote
167
+ status, body = await self._request("GET", f"/v1/pdr/verify/{_urlquote(pdr_b64, safe='')}")
168
+ self._check_status(status, body)
169
+ return body
170
+
171
+ @staticmethod
172
+ def _verify_url(pdr_b64: str) -> str:
173
+ """Public web verifier URL (the /v1/verify/{job_id} path does not exist)."""
174
+ b64url = pdr_b64.replace("+", "-").replace("/", "_").rstrip("=")
175
+ return f"{VERIFY_WEB_BASE}/?pdr={b64url}"
176
+
114
177
  async def wait_for_pdr(
115
178
  self,
116
179
  job_id: str,
@@ -129,7 +192,17 @@ class NotaryClient:
129
192
  raise NotaryServerError(f"Timed out waiting for PDR ({timeout}s)")
130
193
 
131
194
  async def quote(self) -> dict:
132
- status, body = await self._request("POST", "/notarize/quote")
195
+ """POST /v1/notarize/quote — authenticated CI channel.
196
+
197
+ Requires api_key; the public x402 flow does not need a quote
198
+ (price is always $0.01, see the 402 challenge from notarize()).
199
+ """
200
+ if not self.api_key:
201
+ raise NotaryAuthError(
202
+ "quote() requires an API key (authenticated CI channel, POST /v1/notarize/quote). "
203
+ "The public x402 flow needs no quote: price is flat $0.01 per PDR."
204
+ )
205
+ status, body = await self._request("POST", "/v1/notarize/quote")
133
206
  self._check_status(status, body)
134
207
  return body
135
208
 
@@ -152,6 +225,6 @@ class NotaryClient:
152
225
  payload["agent_sig"] = agent_sig
153
226
  if agent_pubkey:
154
227
  payload["agent_pubkey"] = agent_pubkey
155
- status, body = await self._request("POST", "/shield/free", json_data=payload)
228
+ status, body = await self._request("POST", "/v1/shield/free", json_data=payload)
156
229
  self._check_status(status, body)
157
230
  return body
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aotrust-protocol
3
- Version: 2.3.3
3
+ Version: 2.3.4
4
4
  Summary: Asyncio-native SDK for A&O Trust Layer — Agent Notary Service
5
5
  Author: A&O Trust Layer
6
6
  License: MIT
@@ -11,4 +11,5 @@ aotrust_protocol.egg-info/SOURCES.txt
11
11
  aotrust_protocol.egg-info/dependency_links.txt
12
12
  aotrust_protocol.egg-info/requires.txt
13
13
  aotrust_protocol.egg-info/top_level.txt
14
- tests/test_client.py
14
+ tests/test_client.py
15
+ tests/test_live_smoke.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aotrust-protocol"
7
- version = "2.3.3"
7
+ version = "2.3.4"
8
8
  description = "Asyncio-native SDK for A&O Trust Layer — Agent Notary Service"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -40,4 +40,8 @@ include = ["agent_notary*"]
40
40
 
41
41
  [tool.pytest.ini_options]
42
42
  asyncio_mode = "auto"
43
+ markers = [
44
+ "live: live smoke tests against production (run explicitly with -m live)",
45
+ ]
46
+ addopts = "-m 'not live'"
43
47
  testpaths = ["tests"]
@@ -116,7 +116,7 @@ class TestNotaryClientErrors:
116
116
  class TestNotaryClientConstruction:
117
117
  def test_default_base_url(self):
118
118
  client = NotaryClient(api_key="k")
119
- assert "aotrust.link/v1" in client.base_url
119
+ assert client.base_url == "https://api.aotrust.link"
120
120
 
121
121
  def test_custom_base_url(self):
122
122
  client = NotaryClient(api_key="k", base_url="http://localhost:9999/v1")
@@ -0,0 +1,92 @@
1
+ """
2
+ test_live_smoke.py — живой smoke-тест SDK против прода (новый протокол).
3
+
4
+ Запуск ТОЛЬКО явно: `pytest -m live`. По умолчанию скипается.
5
+ НЕ платит: 402-челлендж — граница теста (кошелёк/ключи не трогаются).
6
+
7
+ Путь (ровно тот, что прошёл node-агент):
8
+ 1. Чистый клиент без ключей, дефолтный base_url (без /v1)
9
+ 2. notarize() → 402-челлендж x402 (x402Version/accepts[0].resource — полный URL)
10
+ 3. get_status() на свежем (несуществующем) job → 404 NotaryNotFoundError (эндпоинт жив)
11
+ 4. get_notary_pubkey() → pubkey подтянут с сервера (lazy-fetch работает)
12
+
13
+ Асинхронные вызовы обёрнуты asyncio.run — тест не зависит от pytest_asyncio.
14
+ """
15
+
16
+ import os
17
+ import sys
18
+ import asyncio
19
+ import uuid
20
+ from pathlib import Path
21
+
22
+ PROJECT_ROOT = str(Path(__file__).resolve().parent.parent)
23
+ sys.path.insert(0, PROJECT_ROOT)
24
+
25
+ import pytest
26
+
27
+ pytestmark = [pytest.mark.live]
28
+
29
+
30
+ def _live_guard():
31
+ """Пропускать, если live-тесты выключены (CI/offline)."""
32
+ if os.environ.get("ANS_LIVE_TESTS") == "0":
33
+ pytest.skip("ANS_LIVE_TESTS=0")
34
+
35
+
36
+ def test_live_notarize_402_challenge():
37
+ _live_guard()
38
+ from agent_notary import NotaryClient, NotarizeRequest
39
+ from agent_notary.exceptions import NotaryPaymentError
40
+
41
+ client = NotaryClient() # чистый: без api_key, без pubkey
42
+ assert client.base_url == "https://api.aotrust.link"
43
+ req = NotarizeRequest(tx_hash="0x" + "a" * 64, work_hash="b" * 64)
44
+
45
+ async def run():
46
+ try:
47
+ await client.notarize(req)
48
+ return None
49
+ except NotaryPaymentError as e:
50
+ return str(e)
51
+
52
+ msg = asyncio.run(run())
53
+ assert msg is not None, "notarize() без оплаты должен дать NotaryPaymentError (402)"
54
+ # 402-челлендж: тело содержит x402-структуру с полным resource URL
55
+ assert "402" in msg, f"expected 402 in error body: {msg[:200]}"
56
+ assert "accepts" in msg, f"expected x402 accepts[] in error body: {msg[:200]}"
57
+ assert "resource" in msg, f"expected resource in error body: {msg[:200]}"
58
+
59
+
60
+ def test_live_get_status_404_on_unknown_job():
61
+ _live_guard()
62
+ from agent_notary import NotaryClient
63
+ from agent_notary.exceptions import NotaryNotFoundError
64
+
65
+ client = NotaryClient()
66
+ fresh_job = str(uuid.uuid4())
67
+
68
+ async def run():
69
+ try:
70
+ await client.get_status(fresh_job)
71
+ return None
72
+ except NotaryNotFoundError as e:
73
+ return str(e)
74
+
75
+ msg = asyncio.run(run())
76
+ assert msg is not None, "get_status() на несуществующем job должен дать 404"
77
+
78
+
79
+ def test_live_pubkey_lazy_fetch():
80
+ _live_guard()
81
+ from agent_notary import NotaryClient
82
+
83
+ client = NotaryClient()
84
+
85
+ async def run():
86
+ return await client.get_notary_pubkey()
87
+
88
+ pubkey = asyncio.run(run())
89
+ assert isinstance(pubkey, str) and len(pubkey) == 64, f"pubkey hex 64 chars, got: {pubkey!r}"
90
+ assert all(c in "0123456789abcdef" for c in pubkey.lower())
91
+ # Кеш: второй вызов не ходит в сеть (тот же атрибут)
92
+ assert client._pubkey_fetched == pubkey