aotrust-protocol 2.4.0__tar.gz → 2.5.1__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,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aotrust-protocol
3
- Version: 2.4.0
3
+ Version: 2.5.1
4
4
  Summary: Asyncio-native SDK for A&O Trust Layer — Agent Notary Service
5
- Author: A&O Trust Layer
5
+ Author-email: AOTrust <legal@aotrust.link>
6
6
  License: MIT
7
7
  Classifier: Development Status :: 4 - Beta
8
8
  Classifier: Intended Audience :: Developers
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3.11
12
12
  Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Framework :: AsyncIO
14
14
  Classifier: Topic :: Security :: Cryptography
15
- Requires-Python: >=3.10
15
+ Requires-Python: >=3.11
16
16
  Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Requires-Dist: aiohttp>=3.9
@@ -108,6 +108,43 @@ asyncio.run(main())
108
108
  > `/v1/v1/...` → HTTP 404 on every call. This bit a real user (2026-09-29:
109
109
  > quickstart copy-paste + status lookups by artifact text instead of job_id).
110
110
 
111
+ ## Paid read products (2.5.0)
112
+
113
+ The SDK never signs or settles payments itself: a paid call first raises
114
+ `NotaryPaymentError`, whose message carries the server's x402 challenge. Pay
115
+ with any x402-capable client and retry with `X-PAYMENT` (HTTP) or
116
+ `x402/payment` (MCP); the SDK then parses the 200 response.
117
+
118
+ ```python
119
+ from agent_notary import NotaryClient
120
+ from agent_notary.exceptions import NotaryPaymentError, NotaryPendingError
121
+
122
+ client = NotaryClient()
123
+
124
+ # Evidence pack — $0.02. Default = earliest issuance; seq selects one.
125
+ pack = await client.bundle(work_hash) # dict: picked/issuances/anchor/verify
126
+ pack_at_158 = await client.bundle(work_hash, seq=158)
127
+
128
+ # Signed time attestation — $0.002.
129
+ attest = await client.time_attest(payload_hash) # id/ts/sig_b64/leaf/log_index/anchor
130
+
131
+ # Idempotent lookup by payment tx — free. A 404 is a normal answer.
132
+ found = await client.pdr_by_tx("0x" + "ab" * 32) # {"found": False} when unknown
133
+
134
+ # Anchor API — free.
135
+ status = await client.anchor_status() # mode/latest/log_size
136
+ proof = await client.anchor_proof(186) # RFC 9162 §2.1.3 path
137
+ logproof = await client.anchor_logproof(381) # Phase B, kind 0/1
138
+ cons = await client.anchor_consistency(62, 64) # RFC 9162 §2.1.4
139
+
140
+ try:
141
+ pack = await client.bundle(work_hash)
142
+ except NotaryPaymentError as e:
143
+ print("Pay via x402, then retry with X-PAYMENT / x402/payment:", e)
144
+ except NotaryPendingError as e:
145
+ print(f"Anchor not committed yet; retry in {e.eta_seconds}s at {e.recheck_url}")
146
+ ```
147
+
111
148
  ## Configuration
112
149
 
113
150
  | Env Var | Constructor Arg | Description |
@@ -125,6 +162,15 @@ asyncio.run(main())
125
162
  | GET | `/v1/pdr/{job_id}` | Get PDR |
126
163
  | POST | `/v1/notarize/quote` | Get price quote |
127
164
  | GET | `/.well-known/agent.json` | MCP Server Card |
165
+ | POST | `/notarize` | Anonymous x402 paid PDR ($0.01) |
166
+ | POST | `/v1/attestation/bundle` | Evidence pack ($0.02) |
167
+ | POST | `/v1/attest/time` | Signed time attestation ($0.002) |
168
+ | GET | `/v1/pdr/verify/{pdr_b64}` | Verify a PDR |
169
+ | GET | `/v1/pdr/by-tx/{tx}` | PDR by payment tx |
170
+ | GET | `/v1/anchor/status` | Cumulative anchor status |
171
+ | GET | `/v1/anchor/proof/{seq}` | Inclusion proof by ledger seq |
172
+ | GET | `/v1/anchor/logproof/{log_index}` | Inclusion proof by log index (Phase B) |
173
+ | GET | `/v1/anchor/consistency?from=&to=` | Consistency proof |
128
174
  | GET | `/openapi.json` | OpenAPI 3.0 spec |
129
175
 
130
176
  ## PDR Verification
@@ -143,6 +189,7 @@ from agent_notary.exceptions import (
143
189
  NotaryAuthError, # 401 (authenticated CI surfaces; anonymous x402 gives 402 instead)
144
190
  NotaryPaymentError, # 402 x402-challenge / 403
145
191
  NotaryNotFoundError, # 404
192
+ NotaryPendingError, # 404 NOT_YET_ANCHORED — retry after eta_seconds
146
193
  NotaryValidationError, # 400/422 — invalid params, INVALID_AGENT_SIGNATURE
147
194
  NotaryConflictError, # 409 — tx_hash/job_id already used (idempotency)
148
195
  NotaryServerError, # 5xx, carries status_code
@@ -81,6 +81,43 @@ asyncio.run(main())
81
81
  > `/v1/v1/...` → HTTP 404 on every call. This bit a real user (2026-09-29:
82
82
  > quickstart copy-paste + status lookups by artifact text instead of job_id).
83
83
 
84
+ ## Paid read products (2.5.0)
85
+
86
+ The SDK never signs or settles payments itself: a paid call first raises
87
+ `NotaryPaymentError`, whose message carries the server's x402 challenge. Pay
88
+ with any x402-capable client and retry with `X-PAYMENT` (HTTP) or
89
+ `x402/payment` (MCP); the SDK then parses the 200 response.
90
+
91
+ ```python
92
+ from agent_notary import NotaryClient
93
+ from agent_notary.exceptions import NotaryPaymentError, NotaryPendingError
94
+
95
+ client = NotaryClient()
96
+
97
+ # Evidence pack — $0.02. Default = earliest issuance; seq selects one.
98
+ pack = await client.bundle(work_hash) # dict: picked/issuances/anchor/verify
99
+ pack_at_158 = await client.bundle(work_hash, seq=158)
100
+
101
+ # Signed time attestation — $0.002.
102
+ attest = await client.time_attest(payload_hash) # id/ts/sig_b64/leaf/log_index/anchor
103
+
104
+ # Idempotent lookup by payment tx — free. A 404 is a normal answer.
105
+ found = await client.pdr_by_tx("0x" + "ab" * 32) # {"found": False} when unknown
106
+
107
+ # Anchor API — free.
108
+ status = await client.anchor_status() # mode/latest/log_size
109
+ proof = await client.anchor_proof(186) # RFC 9162 §2.1.3 path
110
+ logproof = await client.anchor_logproof(381) # Phase B, kind 0/1
111
+ cons = await client.anchor_consistency(62, 64) # RFC 9162 §2.1.4
112
+
113
+ try:
114
+ pack = await client.bundle(work_hash)
115
+ except NotaryPaymentError as e:
116
+ print("Pay via x402, then retry with X-PAYMENT / x402/payment:", e)
117
+ except NotaryPendingError as e:
118
+ print(f"Anchor not committed yet; retry in {e.eta_seconds}s at {e.recheck_url}")
119
+ ```
120
+
84
121
  ## Configuration
85
122
 
86
123
  | Env Var | Constructor Arg | Description |
@@ -98,6 +135,15 @@ asyncio.run(main())
98
135
  | GET | `/v1/pdr/{job_id}` | Get PDR |
99
136
  | POST | `/v1/notarize/quote` | Get price quote |
100
137
  | GET | `/.well-known/agent.json` | MCP Server Card |
138
+ | POST | `/notarize` | Anonymous x402 paid PDR ($0.01) |
139
+ | POST | `/v1/attestation/bundle` | Evidence pack ($0.02) |
140
+ | POST | `/v1/attest/time` | Signed time attestation ($0.002) |
141
+ | GET | `/v1/pdr/verify/{pdr_b64}` | Verify a PDR |
142
+ | GET | `/v1/pdr/by-tx/{tx}` | PDR by payment tx |
143
+ | GET | `/v1/anchor/status` | Cumulative anchor status |
144
+ | GET | `/v1/anchor/proof/{seq}` | Inclusion proof by ledger seq |
145
+ | GET | `/v1/anchor/logproof/{log_index}` | Inclusion proof by log index (Phase B) |
146
+ | GET | `/v1/anchor/consistency?from=&to=` | Consistency proof |
101
147
  | GET | `/openapi.json` | OpenAPI 3.0 spec |
102
148
 
103
149
  ## PDR Verification
@@ -116,6 +162,7 @@ from agent_notary.exceptions import (
116
162
  NotaryAuthError, # 401 (authenticated CI surfaces; anonymous x402 gives 402 instead)
117
163
  NotaryPaymentError, # 402 x402-challenge / 403
118
164
  NotaryNotFoundError, # 404
165
+ NotaryPendingError, # 404 NOT_YET_ANCHORED — retry after eta_seconds
119
166
  NotaryValidationError, # 400/422 — invalid params, INVALID_AGENT_SIGNATURE
120
167
  NotaryConflictError, # 409 — tx_hash/job_id already used (idempotency)
121
168
  NotaryServerError, # 5xx, carries status_code
@@ -1,6 +1,6 @@
1
1
  """A&O Trust Layer — Agent Notary SDK (asyncio-native)."""
2
2
 
3
- __version__ = "2.3.7"
3
+ __version__ = "2.5.0"
4
4
 
5
5
  from .client import NotaryClient
6
6
  from .models import NotarizeRequest, NotarizeResult, PdrResult, StatusResult
@@ -9,7 +9,10 @@ from .exceptions import (
9
9
  NotaryAuthError,
10
10
  NotaryNotFoundError,
11
11
  NotaryPaymentError,
12
+ NotaryPendingError,
12
13
  NotaryServerError,
14
+ NotaryValidationError,
15
+ NotaryConflictError,
13
16
  )
14
17
  from .tools import AOTrustTool
15
18
 
@@ -23,6 +26,9 @@ __all__ = [
23
26
  "NotaryAuthError",
24
27
  "NotaryNotFoundError",
25
28
  "NotaryPaymentError",
29
+ "NotaryPendingError",
26
30
  "NotaryServerError",
31
+ "NotaryValidationError",
32
+ "NotaryConflictError",
27
33
  "AOTrustTool",
28
34
  ]
@@ -48,6 +48,7 @@ from .exceptions import (
48
48
  NotaryAuthError,
49
49
  NotaryNotFoundError,
50
50
  NotaryPaymentError,
51
+ NotaryPendingError,
51
52
  NotaryServerError,
52
53
  NotaryValidationError,
53
54
  NotaryConflictError,
@@ -142,6 +143,10 @@ class NotaryClient:
142
143
  await asyncio.sleep(RETRY_DELAYS[attempt])
143
144
  raise NotaryServerError(f"Request failed after {MAX_RETRIES} retries: {last_exc}")
144
145
 
146
+ async def _get_json(self, path: str) -> tuple[int, dict]:
147
+ """GET helper — same retry policy as _request (502/503 backoff)."""
148
+ return await self._request("GET", path)
149
+
145
150
  def _check_status(self, status: int, body: dict) -> None:
146
151
  if status == 401:
147
152
  raise NotaryAuthError(body.get("error", "Unauthorized"))
@@ -291,4 +296,84 @@ class NotaryClient:
291
296
  payload["agent_pubkey"] = agent_pubkey
292
297
  status, body = await self._request("POST", "/v1/shield/free", json_data=payload)
293
298
  self._check_status(status, body)
294
- return body
299
+ return body
300
+
301
+ # ── 2.5.0: paid read products + anchor API ─────────────────────────────
302
+
303
+ async def bundle(self, work_hash: str, seq: int | None = None) -> dict:
304
+ """POST /v1/attestation/bundle — read-only evidence pack ($0.02).
305
+
306
+ Returns the earliest issuance unless `seq` selects a concrete one.
307
+ Without payment the server answers 402 → NotaryPaymentError carrying
308
+ the x402 challenge (`x402/payment` meta on retry).
309
+ """
310
+ payload: dict = {"work_hash": work_hash}
311
+ if seq is not None:
312
+ payload["seq"] = seq
313
+ status, body = await self._request("POST", "/v1/attestation/bundle", json_data=payload)
314
+ self._check_status(status, body)
315
+ return body
316
+
317
+ async def time_attest(self, payload_hash: str, nonce: str | None = None) -> dict:
318
+ """POST /v1/attest/time — signed time attestation ($0.002).
319
+
320
+ Returns id/ts/ts_iso/payload_hash/nonce/kid/alg/sig_b64/leaf/log_index/
321
+ anchor/verify. 402 → NotaryPaymentError.
322
+ """
323
+ payload: dict = {"payload_hash": payload_hash}
324
+ if nonce is not None:
325
+ payload["nonce"] = nonce
326
+ status, body = await self._request("POST", "/v1/attest/time", json_data=payload)
327
+ self._check_status(status, body)
328
+ return body
329
+
330
+ async def pdr_by_tx(self, tx: str) -> dict:
331
+ """GET /v1/pdr/by-tx/{tx} — idempotent lookup by payment transaction.
332
+
333
+ A 404 is a normal answer ({"found": False}); a malformed tx is a
334
+ 400 → NotaryValidationError.
335
+ """
336
+ from urllib.parse import quote as _urlquote
337
+ status, body = await self._get_json(f"/v1/pdr/by-tx/{_urlquote(tx, safe='')}")
338
+ if status == 404:
339
+ return {"found": False}
340
+ self._check_status(status, body)
341
+ return body
342
+
343
+ async def anchor_status(self) -> dict:
344
+ """GET /v1/anchor/status — cumulative Merkle log status."""
345
+ status, body = await self._get_json("/v1/anchor/status")
346
+ self._check_status(status, body)
347
+ return body
348
+
349
+ async def anchor_proof(self, seq: int) -> dict:
350
+ """GET /v1/anchor/proof/{seq} — RFC 9162 inclusion proof.
351
+
352
+ The server answers 409 NOT_YET_ANCHORED (409 = state conflict: the leaf
353
+ exists in the log, the committed slice does not include it yet) — and
354
+ historically 404 with the same code — both raise
355
+ NotaryPendingError(eta_seconds, recheck_url). A 404 NOT_FOUND means the
356
+ seq was never issued; 404 NOT_A_LEAF is a server-side invariant signal.
357
+ """
358
+ status, body = await self._get_json(f"/v1/anchor/proof/{int(seq)}")
359
+ if status in (404, 409) and (body or {}).get("error") == "NOT_YET_ANCHORED":
360
+ raise NotaryPendingError(
361
+ eta_seconds=body.get("eta_seconds"),
362
+ recheck_url=body.get("recheck_url"),
363
+ message="Anchor not published yet; retry after eta_seconds",
364
+ )
365
+ self._check_status(status, body)
366
+ return body
367
+
368
+ async def anchor_logproof(self, log_index: int) -> dict:
369
+ """GET /v1/anchor/logproof/{log_index} — Phase B proof by opaque log_index."""
370
+ status, body = await self._get_json(f"/v1/anchor/logproof/{int(log_index)}")
371
+ self._check_status(status, body)
372
+ return body
373
+
374
+ async def anchor_consistency(self, from_seq: int, to_seq: int) -> dict:
375
+ """GET /v1/anchor/consistency?from=&to= — RFC 9162 consistency proof."""
376
+ status, body = await self._get_json(
377
+ f"/v1/anchor/consistency?from={int(from_seq)}&to={int(to_seq)}")
378
+ self._check_status(status, body)
379
+ return body
@@ -30,4 +30,16 @@ class NotaryValidationError(NotaryError):
30
30
 
31
31
 
32
32
  class NotaryConflictError(NotaryError):
33
- """409 — idempotency conflict: this tx_hash/job_id was already used."""
33
+ """409 — idempotency conflict: this tx_hash/job_id was already used."""
34
+
35
+
36
+ class NotaryPendingError(NotaryError):
37
+ """409/404 NOT_YET_ANCHORED — the leaf/anchor is not committed yet.
38
+
39
+ Retry after `eta_seconds`; `recheck_url` points at the same endpoint.
40
+ """
41
+
42
+ def __init__(self, eta_seconds=None, recheck_url=None, message=None):
43
+ super().__init__(message or "Not anchored yet")
44
+ self.eta_seconds = eta_seconds
45
+ self.recheck_url = recheck_url
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aotrust-protocol
3
- Version: 2.4.0
3
+ Version: 2.5.1
4
4
  Summary: Asyncio-native SDK for A&O Trust Layer — Agent Notary Service
5
- Author: A&O Trust Layer
5
+ Author-email: AOTrust <legal@aotrust.link>
6
6
  License: MIT
7
7
  Classifier: Development Status :: 4 - Beta
8
8
  Classifier: Intended Audience :: Developers
@@ -12,7 +12,7 @@ Classifier: Programming Language :: Python :: 3.11
12
12
  Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Framework :: AsyncIO
14
14
  Classifier: Topic :: Security :: Cryptography
15
- Requires-Python: >=3.10
15
+ Requires-Python: >=3.11
16
16
  Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Requires-Dist: aiohttp>=3.9
@@ -108,6 +108,43 @@ asyncio.run(main())
108
108
  > `/v1/v1/...` → HTTP 404 on every call. This bit a real user (2026-09-29:
109
109
  > quickstart copy-paste + status lookups by artifact text instead of job_id).
110
110
 
111
+ ## Paid read products (2.5.0)
112
+
113
+ The SDK never signs or settles payments itself: a paid call first raises
114
+ `NotaryPaymentError`, whose message carries the server's x402 challenge. Pay
115
+ with any x402-capable client and retry with `X-PAYMENT` (HTTP) or
116
+ `x402/payment` (MCP); the SDK then parses the 200 response.
117
+
118
+ ```python
119
+ from agent_notary import NotaryClient
120
+ from agent_notary.exceptions import NotaryPaymentError, NotaryPendingError
121
+
122
+ client = NotaryClient()
123
+
124
+ # Evidence pack — $0.02. Default = earliest issuance; seq selects one.
125
+ pack = await client.bundle(work_hash) # dict: picked/issuances/anchor/verify
126
+ pack_at_158 = await client.bundle(work_hash, seq=158)
127
+
128
+ # Signed time attestation — $0.002.
129
+ attest = await client.time_attest(payload_hash) # id/ts/sig_b64/leaf/log_index/anchor
130
+
131
+ # Idempotent lookup by payment tx — free. A 404 is a normal answer.
132
+ found = await client.pdr_by_tx("0x" + "ab" * 32) # {"found": False} when unknown
133
+
134
+ # Anchor API — free.
135
+ status = await client.anchor_status() # mode/latest/log_size
136
+ proof = await client.anchor_proof(186) # RFC 9162 §2.1.3 path
137
+ logproof = await client.anchor_logproof(381) # Phase B, kind 0/1
138
+ cons = await client.anchor_consistency(62, 64) # RFC 9162 §2.1.4
139
+
140
+ try:
141
+ pack = await client.bundle(work_hash)
142
+ except NotaryPaymentError as e:
143
+ print("Pay via x402, then retry with X-PAYMENT / x402/payment:", e)
144
+ except NotaryPendingError as e:
145
+ print(f"Anchor not committed yet; retry in {e.eta_seconds}s at {e.recheck_url}")
146
+ ```
147
+
111
148
  ## Configuration
112
149
 
113
150
  | Env Var | Constructor Arg | Description |
@@ -125,6 +162,15 @@ asyncio.run(main())
125
162
  | GET | `/v1/pdr/{job_id}` | Get PDR |
126
163
  | POST | `/v1/notarize/quote` | Get price quote |
127
164
  | GET | `/.well-known/agent.json` | MCP Server Card |
165
+ | POST | `/notarize` | Anonymous x402 paid PDR ($0.01) |
166
+ | POST | `/v1/attestation/bundle` | Evidence pack ($0.02) |
167
+ | POST | `/v1/attest/time` | Signed time attestation ($0.002) |
168
+ | GET | `/v1/pdr/verify/{pdr_b64}` | Verify a PDR |
169
+ | GET | `/v1/pdr/by-tx/{tx}` | PDR by payment tx |
170
+ | GET | `/v1/anchor/status` | Cumulative anchor status |
171
+ | GET | `/v1/anchor/proof/{seq}` | Inclusion proof by ledger seq |
172
+ | GET | `/v1/anchor/logproof/{log_index}` | Inclusion proof by log index (Phase B) |
173
+ | GET | `/v1/anchor/consistency?from=&to=` | Consistency proof |
128
174
  | GET | `/openapi.json` | OpenAPI 3.0 spec |
129
175
 
130
176
  ## PDR Verification
@@ -143,6 +189,7 @@ from agent_notary.exceptions import (
143
189
  NotaryAuthError, # 401 (authenticated CI surfaces; anonymous x402 gives 402 instead)
144
190
  NotaryPaymentError, # 402 x402-challenge / 403
145
191
  NotaryNotFoundError, # 404
192
+ NotaryPendingError, # 404 NOT_YET_ANCHORED — retry after eta_seconds
146
193
  NotaryValidationError, # 400/422 — invalid params, INVALID_AGENT_SIGNATURE
147
194
  NotaryConflictError, # 409 — tx_hash/job_id already used (idempotency)
148
195
  NotaryServerError, # 5xx, carries status_code
@@ -12,4 +12,5 @@ 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
14
  tests/test_client.py
15
- tests/test_live_smoke.py
15
+ tests/test_live_smoke.py
16
+ tests/test_v250_methods.py
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aotrust-protocol"
7
- version = "2.4.0"
7
+ version = "2.5.1"
8
8
  description = "Asyncio-native SDK for A&O Trust Layer — Agent Notary Service"
9
9
  readme = "README.md"
10
- requires-python = ">=3.10"
10
+ requires-python = ">=3.11"
11
11
  license = {text = "MIT"}
12
- authors = [{name = "A&O Trust Layer"}]
12
+ authors = [{name = "AOTrust", email = "legal@aotrust.link"}]
13
13
  classifiers = [
14
14
  "Development Status :: 4 - Beta",
15
15
  "Intended Audience :: Developers",
@@ -76,8 +76,12 @@ def test_live_get_status_404_on_unknown_job():
76
76
  assert msg is not None, "get_status() на несуществующем job должен дать 404"
77
77
 
78
78
 
79
- def test_live_pubkey_lazy_fetch():
79
+ def test_live_pubkey_lazy_fetch(monkeypatch):
80
80
  _live_guard()
81
+ # Isolation: root tests do os.environ.setdefault("NOTARY_PUBKEY", ...), and in a
82
+ # combined run that makes NotaryClient take the "provided key" path (no fetch),
83
+ # so _pubkey_fetched stays None. This test is specifically about lazy fetch.
84
+ monkeypatch.delenv("NOTARY_PUBKEY", raising=False)
81
85
  from agent_notary import NotaryClient
82
86
 
83
87
  client = NotaryClient()
@@ -0,0 +1,225 @@
1
+ """aotrust-protocol 2.5.0 — paid read products and anchor API.
2
+
3
+ Mock transport only (aioresponses): bundle/time_attest, pdr_by_tx, and the
4
+ anchor status/proof/logproof/consistency methods, including the 402 path and
5
+ NOT_YET_ANCHORED → NotaryPendingError mapping.
6
+ """
7
+ import pytest
8
+ import aioresponses.core as _arc
9
+ from aioresponses import aioresponses
10
+ from aiohttp import ClientResponse as _AiohttpClientResponse
11
+
12
+
13
+ class _CompatClientResponse(_AiohttpClientResponse):
14
+ """aiohttp 3.14 requires stream_writer; aioresponses 0.7.9 predates it."""
15
+
16
+ class _Writer:
17
+ output_size = 0
18
+
19
+ def __init__(self, *args, **kwargs):
20
+ kwargs.setdefault("stream_writer", self._Writer())
21
+ super().__init__(*args, **kwargs)
22
+
23
+
24
+ # _build_response() resolves this module-level name at call time.
25
+ _arc.ClientResponse = _CompatClientResponse
26
+
27
+ from agent_notary import NotaryClient
28
+ from agent_notary.exceptions import (
29
+ NotaryNotFoundError,
30
+ NotaryPaymentError,
31
+ NotaryPendingError,
32
+ NotaryValidationError,
33
+ )
34
+
35
+ BASE = "https://api.aotrust.test"
36
+ WORK_HASH = "9" * 64
37
+ PAYLOAD_HASH = "a" * 64
38
+
39
+
40
+ @pytest.fixture
41
+ def client():
42
+ return NotaryClient(base_url=BASE)
43
+
44
+
45
+ @pytest.mark.asyncio
46
+ async def test_bundle_200_and_optional_seq(client):
47
+ with aioresponses() as m:
48
+ m.post(f"{BASE}/v1/attestation/bundle", status=200, payload={
49
+ "work_hash": WORK_HASH,
50
+ "seq": 80,
51
+ "picked": "earliest",
52
+ "issuance_count": 2,
53
+ "issuances": [{"seq": 80}, {"seq": 158}],
54
+ "pdr_b64": "AAA=",
55
+ "anchor": {"mode": "cumulative", "size": 191},
56
+ })
57
+ out = await client.bundle(WORK_HASH)
58
+ assert out["seq"] == 80
59
+ assert out["picked"] == "earliest"
60
+ assert isinstance(out["issuances"], list)
61
+ key = next(k for k in m.requests if k[0].upper() == "POST")
62
+ calls = m.requests[key]
63
+ assert calls[0].kwargs["json"] == {"work_hash": WORK_HASH}
64
+
65
+ with aioresponses() as m:
66
+ m.post(f"{BASE}/v1/attestation/bundle", status=200, payload={
67
+ "work_hash": WORK_HASH, "seq": 158, "picked": "requested"})
68
+ out = await client.bundle(WORK_HASH, seq=158)
69
+ assert out["seq"] == 158
70
+ key = next(k for k in m.requests if k[0].upper() == "POST")
71
+ calls = m.requests[key]
72
+ assert calls[0].kwargs["json"] == {"work_hash": WORK_HASH, "seq": 158}
73
+
74
+
75
+ @pytest.mark.asyncio
76
+ async def test_bundle_402_is_payment_error(client):
77
+ with aioresponses() as m:
78
+ m.post(f"{BASE}/v1/attestation/bundle", status=402, payload={
79
+ "x402Version": 1,
80
+ "accepts": [{"scheme": "exact", "amount": "20000"}],
81
+ "error": "Payment required",
82
+ })
83
+ with pytest.raises(NotaryPaymentError) as exc:
84
+ await client.bundle(WORK_HASH)
85
+ assert "Payment required" in str(exc.value)
86
+
87
+
88
+ @pytest.mark.asyncio
89
+ async def test_time_attest_200_omits_nonce(client):
90
+ with aioresponses() as m:
91
+ m.post(f"{BASE}/v1/attest/time", status=200, payload={
92
+ "id": 7, "ts": 1760000000000000000, "payload_hash": PAYLOAD_HASH,
93
+ "sig_b64": "c2ln", "leaf": "bGVhZg==", "log_index": 381,
94
+ "anchor": {"mode": "cumulative", "log_index": 381},
95
+ })
96
+ out = await client.time_attest(PAYLOAD_HASH)
97
+ assert out["id"] == 7
98
+ assert out["log_index"] == 381
99
+ assert out["anchor"]["mode"] == "cumulative"
100
+ key = next(k for k in m.requests if k[0].upper() == "POST")
101
+ calls = m.requests[key]
102
+ assert calls[0].kwargs["json"] == {"payload_hash": PAYLOAD_HASH}
103
+
104
+
105
+ @pytest.mark.asyncio
106
+ async def test_time_attest_402_is_payment_error(client):
107
+ with aioresponses() as m:
108
+ m.post(f"{BASE}/v1/attest/time", status=402, payload={"error": "Payment required"})
109
+ with pytest.raises(NotaryPaymentError) as exc:
110
+ await client.time_attest(PAYLOAD_HASH)
111
+ assert "Payment required" in str(exc.value)
112
+
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_pdr_by_tx_200(client):
116
+ tx = "0x" + "ab" * 32
117
+ with aioresponses() as m:
118
+ m.get(f"{BASE}/v1/pdr/by-tx/{tx}", status=200, payload={
119
+ "found": True, "seq": 186, "payment_hash": tx[2:],
120
+ "payment_anchor_type": 5, "work_hash": WORK_HASH, "pdr_b64": "AAA="})
121
+ out = await client.pdr_by_tx(tx)
122
+ assert out["found"] is True
123
+ assert out["seq"] == 186
124
+ assert out["payment_hash"] == tx[2:]
125
+
126
+
127
+ @pytest.mark.asyncio
128
+ async def test_pdr_by_tx_404_is_found_false(client):
129
+ with aioresponses() as m:
130
+ m.get(f"{BASE}/v1/pdr/by-tx/0xunknown", status=404, payload={"found": False})
131
+ assert await client.pdr_by_tx("0xunknown") == {"found": False}
132
+
133
+
134
+ @pytest.mark.asyncio
135
+ async def test_pdr_by_tx_400_is_validation_error(client):
136
+ with aioresponses() as m:
137
+ m.get(f"{BASE}/v1/pdr/by-tx/garbage", status=400, payload={"error": "INVALID_TX"})
138
+ with pytest.raises(NotaryValidationError):
139
+ await client.pdr_by_tx("garbage")
140
+
141
+
142
+ @pytest.mark.asyncio
143
+ async def test_anchor_status_200(client):
144
+ with aioresponses() as m:
145
+ m.get(f"{BASE}/v1/anchor/status", status=200, payload={
146
+ "mode": "cumulative",
147
+ "latest": {"anchor_seq": 64, "size": 191, "root": "ff" * 32,
148
+ "near_tx": "tx", "ts": 1},
149
+ "log_size": 191,
150
+ })
151
+ out = await client.anchor_status()
152
+ assert out["mode"] == "cumulative"
153
+ assert out["latest"]["size"] == 191
154
+ assert out["log_size"] == 191
155
+
156
+
157
+ @pytest.mark.asyncio
158
+ async def test_anchor_proof_200(client):
159
+ with aioresponses() as m:
160
+ m.get(f"{BASE}/v1/anchor/proof/190", status=200, payload={
161
+ "mode": "cumulative", "seq": 190, "anchor_seq": 64, "size": 191,
162
+ "index": 189, "path": ["ab" * 32], "root": "ff" * 32,
163
+ })
164
+ out = await client.anchor_proof(190)
165
+ assert out["seq"] == 190
166
+ assert out["index"] == 189
167
+ assert isinstance(out["path"], list)
168
+
169
+
170
+ @pytest.mark.asyncio
171
+ async def test_anchor_proof_not_yet_anchored_is_pending(client):
172
+ # Real server semantics: 409 (state conflict) with error=NOT_YET_ANCHORED.
173
+ with aioresponses() as m:
174
+ m.get(f"{BASE}/v1/anchor/proof/999", status=409, payload={
175
+ "error": "NOT_YET_ANCHORED", "eta_seconds": 3600,
176
+ "recheck_url": "/v1/anchor/proof/999"})
177
+ with pytest.raises(NotaryPendingError) as exc:
178
+ await client.anchor_proof(999)
179
+ assert exc.value.eta_seconds == 3600
180
+ assert exc.value.recheck_url == "/v1/anchor/proof/999"
181
+
182
+
183
+ @pytest.mark.asyncio
184
+ async def test_anchor_proof_pending_404_compat(client):
185
+ # Older deployments answered 404 with the same code — keep the mapping.
186
+ with aioresponses() as m:
187
+ m.get(f"{BASE}/v1/anchor/proof/998", status=404, payload={
188
+ "error": "NOT_YET_ANCHORED", "eta_seconds": 60,
189
+ "recheck_url": "/v1/anchor/proof/998"})
190
+ with pytest.raises(NotaryPendingError):
191
+ await client.anchor_proof(998)
192
+
193
+
194
+ @pytest.mark.asyncio
195
+ async def test_anchor_proof_not_found_is_notary_not_found(client):
196
+ # A seq that was never issued must NOT look like "pending".
197
+ with aioresponses() as m:
198
+ m.get(f"{BASE}/v1/anchor/proof/7777", status=404, payload={"error": "NOT_FOUND"})
199
+ with pytest.raises(NotaryNotFoundError):
200
+ await client.anchor_proof(7777)
201
+
202
+
203
+ @pytest.mark.asyncio
204
+ async def test_anchor_logproof_path_and_200(client):
205
+ with aioresponses() as m:
206
+ m.get(f"{BASE}/v1/anchor/logproof/381", status=200, payload={
207
+ "log_index": 381, "kind": 1, "ref_id": 7, "anchor_seq": 64,
208
+ "size": 191, "path": [], "root": "ff" * 32})
209
+ out = await client.anchor_logproof(381)
210
+ assert out["log_index"] == 381
211
+ assert out["kind"] == 1
212
+
213
+
214
+ @pytest.mark.asyncio
215
+ async def test_anchor_consistency_query(client):
216
+ with aioresponses() as m:
217
+ m.get(f"{BASE}/v1/anchor/consistency?from=62&to=64", status=200, payload={
218
+ "from": {"anchor_seq": 62, "size": 189, "root": "aa" * 32},
219
+ "to": {"anchor_seq": 64, "size": 191, "root": "ff" * 32},
220
+ "proof": ["cd" * 32], "how_to_verify": "RFC 9162 §2.1.4",
221
+ })
222
+ out = await client.anchor_consistency(62, 64)
223
+ assert out["from"]["size"] == 189
224
+ assert out["to"]["anchor_seq"] == 64
225
+ assert isinstance(out["proof"], list)