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 +42 -0
- qed_proof/_anchor.py +75 -0
- qed_proof/_primitives.py +97 -0
- qed_proof/actions.py +99 -0
- qed_proof/client.py +350 -0
- qed_proof/py.typed +0 -0
- qed_proof/receipt.schema.json +156 -0
- qed_proof/types.py +118 -0
- qed_proof/verify.py +131 -0
- qed_proof-0.1.1.dist-info/METADATA +189 -0
- qed_proof-0.1.1.dist-info/RECORD +13 -0
- qed_proof-0.1.1.dist-info/WHEEL +4 -0
- qed_proof-0.1.1.dist-info/licenses/LICENSE +202 -0
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
|
qed_proof/_primitives.py
ADDED
|
@@ -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
|