qed-proof 0.1.1__py3-none-any.whl

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.
qed_proof/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """qed-proof: the Python SDK for QED Proof.
2
+
3
+ from qed_proof import QedProof, actions, verify_receipt
4
+
5
+ qp = QedProof(api_key="qed_sk_...")
6
+ claim = qp.submit_claim(actions.github_commit_push(target="owner/repo", sha="...", branch="main"),
7
+ agent_id="my-agent")
8
+ result = qp.wait_for_verdict(claim.claim_id)
9
+ receipt = qp.get_receipt(result.receipt_id)
10
+ report = verify_receipt(receipt, keys=qp.get_keys())
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from . import actions
15
+ from .actions import Action
16
+ from .client import (
17
+ AsyncQedProof,
18
+ ClaimPage,
19
+ ClaimResult,
20
+ QedProof,
21
+ QedProofError,
22
+ QedProofRateLimited,
23
+ QedProofTimeout,
24
+ )
25
+ from .verify import VerifyReport, verify_receipt
26
+
27
+ __version__ = "0.1.1"
28
+
29
+ __all__ = [
30
+ "Action",
31
+ "actions",
32
+ "AsyncQedProof",
33
+ "ClaimPage",
34
+ "ClaimResult",
35
+ "QedProof",
36
+ "QedProofError",
37
+ "QedProofRateLimited",
38
+ "QedProofTimeout",
39
+ "VerifyReport",
40
+ "verify_receipt",
41
+ "__version__",
42
+ ]
qed_proof/_anchor.py ADDED
@@ -0,0 +1,75 @@
1
+ """Ported, not imported: SPEC §8.4's on-chain anchor check (``oss/spec/tools/anchor_check.py``).
2
+
3
+ Checks a receipt's ``proof.anchor`` (scheme ``eas``) against a public JSON-RPC endpoint. It needs no
4
+ issuer API, only the chain: EAS.getAttestation(uid) must be a live, unrevoked attestation made by an
5
+ address the issuer publishes, under a schema the issuer publishes, whose data decodes to this proof's
6
+ (log_id, tree_size, root_hash).
7
+
8
+ Requires the ``qed-proof[anchor]`` extra (``eth-abi``, ``eth-hash``). Importing this module without it
9
+ installed raises an ``ImportError`` with a clear message, from ``verify_receipt``'s call site.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import urllib.request
15
+
16
+ try:
17
+ from eth_abi import decode as abi_decode
18
+ from eth_hash.auto import keccak
19
+ except ImportError as exc: # pragma: no cover - exercised via verify.py's guard
20
+ raise ImportError(
21
+ "the 'anchor' extra is required to check an on-chain anchor: pip install 'qed-proof[anchor]'"
22
+ ) from exc
23
+
24
+ EAS = "0x4200000000000000000000000000000000000021" # the OP-stack predeploy (Base mainnet + Base Sepolia)
25
+ _GET = keccak(b"getAttestation(bytes32)")[:4]
26
+ _ATT = "(bytes32,bytes32,uint64,uint64,uint64,bytes32,address,address,bool,bytes)"
27
+ _DATA = ["bytes32", "uint64", "bytes32", "uint64", "bytes32", "string"]
28
+
29
+
30
+ def _rpc(url: str, method: str, *params):
31
+ req = urllib.request.Request(
32
+ url,
33
+ json.dumps({"jsonrpc": "2.0", "id": 1, "method": method, "params": list(params)}).encode(),
34
+ {"content-type": "application/json", "user-agent": "qed-proof-py/0.1"},
35
+ )
36
+ with urllib.request.urlopen(req, timeout=20) as r: # noqa: S310 - RPC URL is caller-supplied by design
37
+ body = json.loads(r.read())
38
+ if "error" in body:
39
+ raise RuntimeError(f"{method}: {body['error'].get('message')}")
40
+ return body["result"]
41
+
42
+
43
+ def check_anchor(proof: dict, keyset: dict, rpc_url: str, b64u_decode) -> dict:
44
+ """Return {"ok": bool, "reason": str, "proven_by": unix seconds | None}. Any doubt -> ok False, never a pass."""
45
+ a = proof["anchor"]
46
+ try:
47
+ if a.get("scheme") != "eas":
48
+ return {"ok": False, "reason": "unknown_scheme", "proven_by": None}
49
+ if int(_rpc(rpc_url, "eth_chainId"), 16) != int(str(a["chain"]).split(":")[1]):
50
+ return {"ok": False, "reason": "rpc_chain_mismatch", "proven_by": None}
51
+ uid = bytes.fromhex(a["uid"][2:])
52
+ out = _rpc(rpc_url, "eth_call", {"to": EAS, "data": "0x" + (_GET + uid).hex()}, "latest")
53
+ (att,) = abi_decode([_ATT], bytes.fromhex(out[2:]))
54
+ uid_, schema, time_, _exp, revoked_at, _ref, _recipient, attester, _revocable, data = att
55
+ except Exception as exc: # network, decode or shape errors: the anchor is unproven, not proven
56
+ return {"ok": False, "reason": f"unreadable:{type(exc).__name__}", "proven_by": None}
57
+ if uid_ != uid or time_ == 0:
58
+ return {"ok": False, "reason": "attestation_not_found", "proven_by": None}
59
+ if revoked_at:
60
+ return {"ok": False, "reason": "attestation_revoked", "proven_by": None}
61
+ if attester.lower() not in {x.lower() for x in keyset.get("anchor_addresses", [])}:
62
+ return {"ok": False, "reason": "attester_not_published", "proven_by": None}
63
+ if "0x" + schema.hex() not in {x.lower() for x in keyset.get("anchor_schemas", [])}:
64
+ return {"ok": False, "reason": "schema_not_published", "proven_by": None}
65
+ try:
66
+ log_id, size, root, _prev_size, _prev_root, _spec = abi_decode(_DATA, data)
67
+ except Exception:
68
+ return {"ok": False, "reason": "data_undecodable", "proven_by": None}
69
+ if log_id != b64u_decode(proof["log_id"]) or size != a["tree_size"]:
70
+ return {"ok": False, "reason": "log_or_size_mismatch", "proven_by": None}
71
+ if a["tree_size"] != proof["tree_size"]:
72
+ return {"ok": False, "reason": "consistency_proof_required", "proven_by": None} # this issuer proves at the anchored size
73
+ if root != b64u_decode(proof["root_hash"]):
74
+ return {"ok": False, "reason": "root_mismatch", "proven_by": None}
75
+ return {"ok": True, "reason": "anchored", "proven_by": time_} # EAS sets time = block.timestamp
@@ -0,0 +1,97 @@
1
+ """Ported, not imported: the poaw/0.1 receipt primitives (SPEC.md sections 3, 5 and 8).
2
+
3
+ This is a line-for-line port of ``oss/core-python/src/poaw_core/__init__.py`` from the QED Proof
4
+ monorepo, trimmed to what a *verifier* needs (no tree-building or consistency-proof helpers, since
5
+ the SDK only checks receipts it is handed). Keep this in sync with the reference by hand: the
6
+ conformance suite in ``tests/test_conformance.py`` is what actually proves it matches.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ from typing import Any
13
+
14
+ import rfc8785
15
+ from cryptography.exceptions import InvalidSignature
16
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
17
+
18
+ SPEC_VERSION = "poaw/0.1"
19
+ SIG_DOMAIN = b"POAW-RECEIPT-V0\n"
20
+
21
+
22
+ # --- encoding (SPEC §3) ------------------------------------------------------------------------
23
+ def b64u(data: bytes) -> str:
24
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
25
+
26
+
27
+ def b64u_decode(text: str) -> bytes:
28
+ return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
29
+
30
+
31
+ def jcs(obj: Any) -> bytes:
32
+ return rfc8785.dumps(obj)
33
+
34
+
35
+ def sha256(data: bytes) -> bytes:
36
+ return hashlib.sha256(data).digest()
37
+
38
+
39
+ def has_float(obj: Any) -> bool:
40
+ """SPEC §3: receipts carry integers only."""
41
+ if isinstance(obj, bool):
42
+ return False
43
+ if isinstance(obj, float):
44
+ return True
45
+ if isinstance(obj, dict):
46
+ return any(has_float(v) for v in obj.values())
47
+ if isinstance(obj, list):
48
+ return any(has_float(v) for v in obj)
49
+ return False
50
+
51
+
52
+ # --- keys + signatures (SPEC §5) ---------------------------------------------------------------
53
+ def key_id(pk_raw: bytes) -> str:
54
+ return "ed25519:" + b64u(sha256(pk_raw))
55
+
56
+
57
+ def verify_signature(pk_raw: bytes, body: dict, sig_value: str) -> bool:
58
+ try:
59
+ Ed25519PublicKey.from_public_bytes(pk_raw).verify(b64u_decode(sig_value), SIG_DOMAIN + jcs(body))
60
+ return True
61
+ except (InvalidSignature, ValueError):
62
+ return False
63
+
64
+
65
+ def claim_digest(claim: dict) -> str:
66
+ stripped = {k: v for k, v in claim.items() if k != "claim_digest"}
67
+ return b64u(sha256(jcs(stripped)))
68
+
69
+
70
+ # --- Merkle log, RFC 6962 (SPEC §8) -------------------------------------------------------------
71
+ def leaf_hash(receipt: dict) -> bytes:
72
+ return sha256(b"\x00" + jcs({"body": receipt["body"], "signature": receipt["signature"]}))
73
+
74
+
75
+ def _node(left: bytes, right: bytes) -> bytes:
76
+ return sha256(b"\x01" + left + right)
77
+
78
+
79
+ def root_from_inclusion(index: int, size: int, leaf: bytes, path: list[bytes]) -> bytes | None:
80
+ """RFC 9162 §2.1.3.2 audit-path verification. Returns the computed root, or None if the path is malformed."""
81
+ if index >= size:
82
+ return None
83
+ fn, sn, r = index, size - 1, leaf
84
+ for p in path:
85
+ if sn == 0:
86
+ return None
87
+ if fn & 1 or fn == sn:
88
+ r = _node(p, r)
89
+ if not fn & 1:
90
+ while fn and not fn & 1:
91
+ fn >>= 1
92
+ sn >>= 1
93
+ else:
94
+ r = _node(r, p)
95
+ fn >>= 1
96
+ sn >>= 1
97
+ return r if sn == 0 else None
qed_proof/actions.py ADDED
@@ -0,0 +1,99 @@
1
+ """Typed helpers that build an :class:`Action` for the five live verifier profiles plus
2
+ ``http.url.status``. Param names, types and target formats follow ``oss/spec/profiles/*.md`` and the
3
+ node's verifier ``Params`` models (``oss/node/src/poaw_node/verifiers/{github,x,slack,http}.py``).
4
+
5
+ Each helper validates cheaply-checkable shape locally (regexes for a commit SHA, a fingerprint hex
6
+ digest, a Slack target, ...) and raises ``ValueError`` with a clear message on a bad value. It cannot
7
+ catch everything the node will reject (e.g. an unreadable repo) — that still comes back as a 422 from
8
+ ``submit_claim``.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ _REPO = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
17
+ _SHA40 = re.compile(r"^[0-9a-f]{40}$")
18
+ _HEX64 = re.compile(r"^[0-9a-f]{64}$")
19
+ _HANDLE = re.compile(r"^@[A-Za-z0-9_]{1,15}$")
20
+ _SLACK_TARGET = re.compile(r"^slack://(T[A-Z0-9]{2,20})/([CG][A-Z0-9]{2,20})$")
21
+ _SLACK_TS = re.compile(r"^[0-9]{10}\.[0-9]{6}$")
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Action:
26
+ """An action + target + params ready to hand to ``QedProof.submit_claim``."""
27
+
28
+ action: str
29
+ target: str
30
+ params: dict[str, Any]
31
+
32
+
33
+ def _require(cond: bool, msg: str) -> None:
34
+ if not cond:
35
+ raise ValueError(msg)
36
+
37
+
38
+ def github_commit_push(target: str, sha: str, branch: str) -> Action:
39
+ """`github.commit.push` v1: `branch` contains `sha` on the remote."""
40
+ _require(bool(_REPO.match(target)), "target must look like 'owner/repo'")
41
+ _require(bool(_SHA40.match(sha)), "sha must be 40 lowercase hex characters")
42
+ _require(isinstance(branch, str) and bool(branch), "branch must be a non-empty string")
43
+ return Action("github.commit.push", target, {"sha": sha, "branch": branch})
44
+
45
+
46
+ def github_pr_open(target: str, number: int, base: str | None = None, head_sha: str | None = None) -> Action:
47
+ """`github.pr.open` v1: the PR exists (and, if given, matches `base` / `head_sha`)."""
48
+ _require(bool(_REPO.match(target)), "target must look like 'owner/repo'")
49
+ _require(isinstance(number, int) and number > 0, "number must be a positive integer")
50
+ if head_sha is not None:
51
+ _require(bool(_SHA40.match(head_sha)), "head_sha must be 40 lowercase hex characters")
52
+ params: dict[str, Any] = {"number": number}
53
+ if base is not None:
54
+ params["base"] = base
55
+ if head_sha is not None:
56
+ params["head_sha"] = head_sha
57
+ return Action("github.pr.open", target, params)
58
+
59
+
60
+ def github_checks_pass(target: str, sha: str) -> Action:
61
+ """`github.checks.pass` v1: every check run for `sha` concluded successfully."""
62
+ _require(bool(_REPO.match(target)), "target must look like 'owner/repo'")
63
+ _require(bool(_SHA40.match(sha)), "sha must be 40 lowercase hex characters")
64
+ return Action("github.checks.pass", target, {"sha": sha})
65
+
66
+
67
+ def x_post_publish(target: str, post_id: str, text_sha256: str | None = None) -> Action:
68
+ """`x.post.publish` v1: the post exists, written by the account `target` (an `@handle`) is bound to."""
69
+ _require(bool(_HANDLE.match(target)), "target must be an '@handle' (1-15 of A-Za-z0-9_)")
70
+ _require(isinstance(post_id, str) and post_id.isdigit() and 1 <= len(post_id) <= 20,
71
+ "post_id must be a string of 1-20 digits")
72
+ if text_sha256 is not None:
73
+ _require(bool(_HEX64.match(text_sha256)), "text_sha256 must be 64 lowercase hex characters")
74
+ params: dict[str, Any] = {"post_id": post_id}
75
+ if text_sha256 is not None:
76
+ params["text_sha256"] = text_sha256
77
+ return Action("x.post.publish", target, params)
78
+
79
+
80
+ def slack_message_post(target: str, ts: str, text_sha256: str | None = None) -> Action:
81
+ """`slack.message.post` v1: the message exists at `slack://<team>/<channel>` timestamp `ts`."""
82
+ _require(bool(_SLACK_TARGET.match(target)), "target must look like 'slack://<team_id>/<channel_id>'")
83
+ _require(bool(_SLACK_TS.match(ts)), "ts must look like '1234567890.123456'")
84
+ if text_sha256 is not None:
85
+ _require(bool(_HEX64.match(text_sha256)), "text_sha256 must be 64 lowercase hex characters")
86
+ params: dict[str, Any] = {"ts": ts}
87
+ if text_sha256 is not None:
88
+ params["text_sha256"] = text_sha256
89
+ return Action("slack.message.post", target, params)
90
+
91
+
92
+ def http_url_status(target: str, status: int = 200, content_fingerprint: str | None = None) -> Action:
93
+ """`http.url.status` v1: an HTTPS URL returns `status` (and, if given, a matching content fingerprint)."""
94
+ _require(isinstance(target, str) and target.startswith("https://"), "target must be an https:// URL")
95
+ _require(isinstance(status, int) and 100 <= status <= 599, "status must be a valid HTTP status code")
96
+ params: dict[str, Any] = {"status": status}
97
+ if content_fingerprint is not None:
98
+ params["content_fingerprint"] = content_fingerprint
99
+ return Action("http.url.status", target, params)
qed_proof/client.py ADDED
@@ -0,0 +1,350 @@
1
+ """HTTP client for QED Proof: submit claims, poll for a verdict, fetch a receipt and the public keys.
2
+
3
+ Endpoint shapes follow ``oss/node/src/poaw_node/app.py`` (``ClaimIn`` in ``models.py``, and
4
+ ``Store.claim_status`` / ``Store.receipt_with_proof`` in ``store.py``).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import time
10
+ import uuid
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from .actions import Action
18
+ from .verify import VerifyReport, verify_receipt
19
+
20
+ __all__ = [
21
+ "QedProof", "AsyncQedProof", "ClaimResult", "ClaimPage",
22
+ "QedProofError", "QedProofRateLimited", "QedProofTimeout",
23
+ ]
24
+
25
+ DEFAULT_BASE_URL = "https://api.qedproof.site"
26
+ DEFAULT_TIMEOUT = 30
27
+ _ENV_KEY = "QED_PROOF_API_KEY"
28
+ LIST_CLAIMS_DEFAULT_LIMIT = 50
29
+ LIST_CLAIMS_MIN_LIMIT = 1
30
+ LIST_CLAIMS_MAX_LIMIT = 200
31
+
32
+
33
+ class QedProofError(Exception):
34
+ """An error response from the QED Proof API (a FastAPI ``{"detail": ...}`` body).
35
+
36
+ The API key is never included in this exception's message, repr or args — only the status code
37
+ and the server's own detail text.
38
+ """
39
+
40
+ def __init__(self, status_code: int, detail: Any):
41
+ self.status_code = status_code
42
+ self.detail = detail
43
+ super().__init__(f"QED Proof API error {status_code}: {detail!r}")
44
+
45
+
46
+ class QedProofRateLimited(QedProofError):
47
+ """A 429 response. ``retry_after`` is the ``Retry-After`` header in whole seconds, if present."""
48
+
49
+ def __init__(self, status_code: int, detail: Any, retry_after: int | None):
50
+ self.retry_after = retry_after
51
+ super().__init__(status_code, detail)
52
+
53
+
54
+ class QedProofTimeout(Exception):
55
+ """``wait_for_verdict`` did not observe a decided claim within ``timeout`` seconds."""
56
+
57
+ def __init__(self, claim_id: str, timeout: float):
58
+ self.claim_id = claim_id
59
+ self.timeout = timeout
60
+ super().__init__(f"claim {claim_id} was not decided within {timeout}s")
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class ClaimResult:
65
+ """A claim as the API reports it (``Store.claim_status`` / the ``POST /v1/claims`` response)."""
66
+
67
+ claim_id: str
68
+ state: str # "queued" | "decided"
69
+ attempts: int
70
+ receipt_id: str | None
71
+ verdict: str | None
72
+ created: bool | None = None # only set on the response to submit_claim
73
+ raw: dict[str, Any] = field(default_factory=dict)
74
+
75
+ @property
76
+ def decided(self) -> bool:
77
+ return self.state == "decided"
78
+
79
+ @classmethod
80
+ def _from_dict(cls, d: dict[str, Any]) -> "ClaimResult":
81
+ return cls(
82
+ claim_id=d["claim_id"], state=d["state"], attempts=d.get("attempts", 0),
83
+ receipt_id=d.get("receipt_id"), verdict=d.get("verdict"), created=d.get("created"), raw=d,
84
+ )
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class ClaimPage:
89
+ """One page of ``GET /v1/claims`` (newest first, keyset-paginated)."""
90
+
91
+ claims: list[ClaimResult]
92
+ next_cursor: str | None
93
+ raw: dict[str, Any] = field(default_factory=dict)
94
+
95
+
96
+ def _list_claims_params(*, limit: int, cursor: str | None, agent_id: str | None, action: str | None,
97
+ verdict: str | None, state: str | None) -> dict[str, Any]:
98
+ if not (LIST_CLAIMS_MIN_LIMIT <= limit <= LIST_CLAIMS_MAX_LIMIT):
99
+ raise ValueError(f"limit must be between {LIST_CLAIMS_MIN_LIMIT} and {LIST_CLAIMS_MAX_LIMIT}, got {limit}")
100
+ params: dict[str, Any] = {"limit": limit}
101
+ if cursor is not None:
102
+ params["cursor"] = cursor
103
+ if agent_id is not None:
104
+ params["agent_id"] = agent_id
105
+ if action is not None:
106
+ params["action"] = action
107
+ if verdict is not None:
108
+ params["verdict"] = verdict
109
+ if state is not None:
110
+ params["state"] = state
111
+ return params
112
+
113
+
114
+ def _claim_page_from_dict(d: dict[str, Any]) -> ClaimPage:
115
+ return ClaimPage(claims=[ClaimResult._from_dict(c) for c in d.get("claims", [])],
116
+ next_cursor=d.get("next_cursor"), raw=d)
117
+
118
+
119
+ def _now_iso() -> str:
120
+ now = datetime.now(timezone.utc)
121
+ return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
122
+
123
+
124
+ def _claim_body(action: Action | str, *, target: str | None, params: dict[str, Any] | None,
125
+ agent_id: str, client_claim_id: str | None, claimed_at: str | None) -> dict[str, Any]:
126
+ if isinstance(action, Action):
127
+ action_name, resolved_target, resolved_params = action.action, action.target, dict(action.params)
128
+ else:
129
+ action_name, resolved_target, resolved_params = action, target, dict(params or {})
130
+ if resolved_target is None:
131
+ raise ValueError("target is required when action is a plain string")
132
+ return {
133
+ "client_claim_id": client_claim_id or str(uuid.uuid4()),
134
+ "agent_id": agent_id,
135
+ "action": action_name,
136
+ "target": resolved_target,
137
+ "params": resolved_params,
138
+ "claimed_at": claimed_at or _now_iso(),
139
+ }
140
+
141
+
142
+ def _raise_for_status(resp: httpx.Response) -> None:
143
+ if resp.status_code < 400:
144
+ return
145
+ try:
146
+ detail = resp.json().get("detail", resp.text)
147
+ except ValueError:
148
+ detail = resp.text
149
+ if resp.status_code == 429:
150
+ retry_after = resp.headers.get("Retry-After")
151
+ raise QedProofRateLimited(429, detail, int(retry_after) if retry_after and retry_after.isdigit() else None)
152
+ raise QedProofError(resp.status_code, detail)
153
+
154
+
155
+ class QedProof:
156
+ """Synchronous client. ``api_key`` falls back to the ``QED_PROOF_API_KEY`` environment variable."""
157
+
158
+ def __init__(self, api_key: str | None = None, base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT,
159
+ http_client: httpx.Client | None = None):
160
+ self._api_key = api_key or os.environ.get(_ENV_KEY)
161
+ self._base_url = base_url.rstrip("/")
162
+ self._client = http_client or httpx.Client(timeout=timeout)
163
+ self._owns_client = http_client is None
164
+
165
+ def __repr__(self) -> str: # never leak the key
166
+ return f"QedProof(base_url={self._base_url!r})"
167
+
168
+ def close(self) -> None:
169
+ if self._owns_client:
170
+ self._client.close()
171
+
172
+ def __enter__(self) -> "QedProof":
173
+ return self
174
+
175
+ def __exit__(self, *exc: object) -> None:
176
+ self.close()
177
+
178
+ def _auth_headers(self) -> dict[str, str]:
179
+ if not self._api_key:
180
+ raise QedProofError(401, "no API key set (pass api_key= or set QED_PROOF_API_KEY)")
181
+ return {"Authorization": f"Bearer {self._api_key}"}
182
+
183
+ def submit_claim(self, action: Action | str, *, target: str | None = None, params: dict[str, Any] | None = None,
184
+ agent_id: str, client_claim_id: str | None = None, claimed_at: str | None = None) -> ClaimResult:
185
+ body = _claim_body(action, target=target, params=params, agent_id=agent_id,
186
+ client_claim_id=client_claim_id, claimed_at=claimed_at)
187
+ resp = self._client.post(f"{self._base_url}/v1/claims", json=body, headers=self._auth_headers())
188
+ _raise_for_status(resp)
189
+ return ClaimResult._from_dict(resp.json())
190
+
191
+ def get_claim(self, claim_id: str) -> ClaimResult:
192
+ resp = self._client.get(f"{self._base_url}/v1/claims/{claim_id}", headers=self._auth_headers())
193
+ _raise_for_status(resp)
194
+ return ClaimResult._from_dict(resp.json())
195
+
196
+ def wait_for_verdict(self, claim_id: str, timeout: float = 120, poll_interval: float = 2) -> ClaimResult:
197
+ deadline = time.monotonic() + timeout
198
+ interval = poll_interval
199
+ while True:
200
+ try:
201
+ claim = self.get_claim(claim_id)
202
+ except QedProofRateLimited as exc:
203
+ remaining = deadline - time.monotonic()
204
+ if remaining <= 0:
205
+ raise QedProofTimeout(claim_id, timeout) from exc
206
+ time.sleep(min(exc.retry_after or interval, remaining))
207
+ continue
208
+ if claim.decided:
209
+ return claim
210
+ remaining = deadline - time.monotonic()
211
+ if remaining <= 0:
212
+ raise QedProofTimeout(claim_id, timeout)
213
+ time.sleep(min(interval, remaining))
214
+ interval = min(interval * 2, 30)
215
+
216
+ def list_claims(self, *, limit: int = LIST_CLAIMS_DEFAULT_LIMIT, cursor: str | None = None,
217
+ agent_id: str | None = None, action: str | None = None, verdict: str | None = None,
218
+ state: str | None = None) -> ClaimPage:
219
+ """``GET /v1/claims``, newest first. Raises :class:`ValueError` locally if ``limit`` is out of range."""
220
+ params = _list_claims_params(limit=limit, cursor=cursor, agent_id=agent_id, action=action,
221
+ verdict=verdict, state=state)
222
+ resp = self._client.get(f"{self._base_url}/v1/claims", params=params, headers=self._auth_headers())
223
+ _raise_for_status(resp)
224
+ return _claim_page_from_dict(resp.json())
225
+
226
+ def iter_claims(self, *, page_size: int = LIST_CLAIMS_DEFAULT_LIMIT, agent_id: str | None = None,
227
+ action: str | None = None, verdict: str | None = None, state: str | None = None):
228
+ """Yield every :class:`ClaimResult` matching the filters, following ``next_cursor`` until exhausted."""
229
+ cursor: str | None = None
230
+ while True:
231
+ page = self.list_claims(limit=page_size, cursor=cursor, agent_id=agent_id, action=action,
232
+ verdict=verdict, state=state)
233
+ yield from page.claims
234
+ if page.next_cursor is None:
235
+ return
236
+ cursor = page.next_cursor
237
+
238
+ def get_receipt(self, receipt_id: str) -> dict[str, Any]:
239
+ resp = self._client.get(f"{self._base_url}/v1/receipts/{receipt_id}") # no auth: receipts are public
240
+ _raise_for_status(resp)
241
+ return resp.json()
242
+
243
+ def get_keys(self) -> dict[str, Any]:
244
+ resp = self._client.get(f"{self._base_url}/.well-known/poaw-keys.json")
245
+ _raise_for_status(resp)
246
+ return resp.json()
247
+
248
+ def verify(self, receipt: dict[str, Any], rpc_url: str | None = None) -> VerifyReport:
249
+ keys = self.get_keys()
250
+ return verify_receipt(receipt, keys, rpc_url=rpc_url)
251
+
252
+
253
+ class AsyncQedProof:
254
+ """Asynchronous client with the same surface as :class:`QedProof`, over ``httpx.AsyncClient``."""
255
+
256
+ def __init__(self, api_key: str | None = None, base_url: str = DEFAULT_BASE_URL, timeout: float = DEFAULT_TIMEOUT,
257
+ http_client: httpx.AsyncClient | None = None):
258
+ self._api_key = api_key or os.environ.get(_ENV_KEY)
259
+ self._base_url = base_url.rstrip("/")
260
+ self._client = http_client or httpx.AsyncClient(timeout=timeout)
261
+ self._owns_client = http_client is None
262
+
263
+ def __repr__(self) -> str:
264
+ return f"AsyncQedProof(base_url={self._base_url!r})"
265
+
266
+ async def aclose(self) -> None:
267
+ if self._owns_client:
268
+ await self._client.aclose()
269
+
270
+ async def __aenter__(self) -> "AsyncQedProof":
271
+ return self
272
+
273
+ async def __aexit__(self, *exc: object) -> None:
274
+ await self.aclose()
275
+
276
+ def _auth_headers(self) -> dict[str, str]:
277
+ if not self._api_key:
278
+ raise QedProofError(401, "no API key set (pass api_key= or set QED_PROOF_API_KEY)")
279
+ return {"Authorization": f"Bearer {self._api_key}"}
280
+
281
+ async def submit_claim(self, action: Action | str, *, target: str | None = None, params: dict[str, Any] | None = None,
282
+ agent_id: str, client_claim_id: str | None = None, claimed_at: str | None = None) -> ClaimResult:
283
+ body = _claim_body(action, target=target, params=params, agent_id=agent_id,
284
+ client_claim_id=client_claim_id, claimed_at=claimed_at)
285
+ resp = await self._client.post(f"{self._base_url}/v1/claims", json=body, headers=self._auth_headers())
286
+ _raise_for_status(resp)
287
+ return ClaimResult._from_dict(resp.json())
288
+
289
+ async def get_claim(self, claim_id: str) -> ClaimResult:
290
+ resp = await self._client.get(f"{self._base_url}/v1/claims/{claim_id}", headers=self._auth_headers())
291
+ _raise_for_status(resp)
292
+ return ClaimResult._from_dict(resp.json())
293
+
294
+ async def wait_for_verdict(self, claim_id: str, timeout: float = 120, poll_interval: float = 2) -> ClaimResult:
295
+ import asyncio
296
+ deadline = time.monotonic() + timeout
297
+ interval = poll_interval
298
+ while True:
299
+ try:
300
+ claim = await self.get_claim(claim_id)
301
+ except QedProofRateLimited as exc:
302
+ remaining = deadline - time.monotonic()
303
+ if remaining <= 0:
304
+ raise QedProofTimeout(claim_id, timeout) from exc
305
+ await asyncio.sleep(min(exc.retry_after or interval, remaining))
306
+ continue
307
+ if claim.decided:
308
+ return claim
309
+ remaining = deadline - time.monotonic()
310
+ if remaining <= 0:
311
+ raise QedProofTimeout(claim_id, timeout)
312
+ await asyncio.sleep(min(interval, remaining))
313
+ interval = min(interval * 2, 30)
314
+
315
+ async def list_claims(self, *, limit: int = LIST_CLAIMS_DEFAULT_LIMIT, cursor: str | None = None,
316
+ agent_id: str | None = None, action: str | None = None, verdict: str | None = None,
317
+ state: str | None = None) -> ClaimPage:
318
+ """``GET /v1/claims``, newest first. Raises :class:`ValueError` locally if ``limit`` is out of range."""
319
+ params = _list_claims_params(limit=limit, cursor=cursor, agent_id=agent_id, action=action,
320
+ verdict=verdict, state=state)
321
+ resp = await self._client.get(f"{self._base_url}/v1/claims", params=params, headers=self._auth_headers())
322
+ _raise_for_status(resp)
323
+ return _claim_page_from_dict(resp.json())
324
+
325
+ async def iter_claims(self, *, page_size: int = LIST_CLAIMS_DEFAULT_LIMIT, agent_id: str | None = None,
326
+ action: str | None = None, verdict: str | None = None, state: str | None = None):
327
+ """Async-yield every :class:`ClaimResult` matching the filters, following ``next_cursor`` until exhausted."""
328
+ cursor: str | None = None
329
+ while True:
330
+ page = await self.list_claims(limit=page_size, cursor=cursor, agent_id=agent_id, action=action,
331
+ verdict=verdict, state=state)
332
+ for c in page.claims:
333
+ yield c
334
+ if page.next_cursor is None:
335
+ return
336
+ cursor = page.next_cursor
337
+
338
+ async def get_receipt(self, receipt_id: str) -> dict[str, Any]:
339
+ resp = await self._client.get(f"{self._base_url}/v1/receipts/{receipt_id}")
340
+ _raise_for_status(resp)
341
+ return resp.json()
342
+
343
+ async def get_keys(self) -> dict[str, Any]:
344
+ resp = await self._client.get(f"{self._base_url}/.well-known/poaw-keys.json")
345
+ _raise_for_status(resp)
346
+ return resp.json()
347
+
348
+ async def verify(self, receipt: dict[str, Any], rpc_url: str | None = None) -> VerifyReport:
349
+ keys = await self.get_keys()
350
+ return verify_receipt(receipt, keys, rpc_url=rpc_url)
qed_proof/py.typed ADDED
File without changes