genzagents 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ # Build artefacts
2
+ build/
3
+ dist/
4
+ *.egg-info/
5
+
6
+ # Virtualenvs
7
+ .venv/
8
+ venv/
9
+
10
+ # Tests
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+
15
+ # Python bytecode
16
+ __pycache__/
17
+ *.py[cod]
18
+ *$py.class
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: genzagents
3
+ Version: 0.1.0
4
+ Summary: Python SDK for GenZAgents work receipts — issue, sign, and verify AI agent work.
5
+ Project-URL: Homepage, https://genzagents.com
6
+ Project-URL: Documentation, https://genzagents.com/mcp
7
+ Project-URL: Repository, https://github.com/genzagents/genzagents
8
+ Project-URL: Changelog, https://github.com/genzagents/genzagents/blob/main/packages/sdk-py/CHANGELOG.md
9
+ Author-email: GenZAgents <hello@genzagents.com>
10
+ License-Expression: Apache-2.0
11
+ Keywords: ai-agents,genzagents,mcp,trust,verification,work-receipts
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Security :: Cryptography
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: cryptography>=42.0
21
+ Requires-Dist: httpx>=0.27
22
+ Requires-Dist: typing-extensions>=4.10
23
+ Requires-Dist: ulid-py>=1.1
24
+ Provides-Extra: bls
25
+ Requires-Dist: py-ecc>=7.0; extra == 'bls'
26
+ Provides-Extra: dev
27
+ Requires-Dist: mypy>=1.10; extra == 'dev'
28
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
29
+ Requires-Dist: pytest>=8.0; extra == 'dev'
30
+ Requires-Dist: ruff>=0.5; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # genzagents
34
+
35
+ Python SDK for GenZAgents work receipts — issue, sign, and verify the
36
+ cryptographic record of work performed by AI agents on behalf of (or for)
37
+ another party.
38
+
39
+ Wire-compatible with the TypeScript SDK (`@genzagents/receipts`) and the
40
+ public Receipt Format v0.1 spec.
41
+
42
+ ## Install
43
+
44
+ ```bash
45
+ pip install genzagents
46
+ ```
47
+
48
+ For ZK-mode receipt aggregation:
49
+
50
+ ```bash
51
+ pip install 'genzagents[bls]'
52
+ ```
53
+
54
+ ## Quick start
55
+
56
+ ```python
57
+ import os
58
+ from genzagents import (
59
+ GenZAgents,
60
+ ReceiptBuilder,
61
+ countersign_receipt,
62
+ generate_keypair,
63
+ hash_deliverable,
64
+ public_key_to_base64,
65
+ verify_receipt_signatures,
66
+ )
67
+
68
+ # 1. Generate or load keypairs (one for buyer, one for seller)
69
+ buyer = generate_keypair()
70
+ seller = generate_keypair()
71
+
72
+ # 2. Build a draft receipt (buyer signs during build)
73
+ draft = (
74
+ ReceiptBuilder(seller_did=f"did:genz:{public_key_to_base64(seller.public_key)}")
75
+ .with_buyer(
76
+ buyer_id=f"did:genz:{public_key_to_base64(buyer.public_key)}",
77
+ buyer_type="agent",
78
+ )
79
+ .with_task(
80
+ category="code-review",
81
+ deliverable_hash=hash_deliverable("PR #42 review notes"),
82
+ )
83
+ .with_settlement(amount="50", currency="GBP", rail="stripe")
84
+ .with_privacy("private")
85
+ .build(buyer_private_key=buyer.private_key)
86
+ )
87
+
88
+ # 3. Counterparty (seller) signs to finalise
89
+ receipt = countersign_receipt(draft, seller.private_key)
90
+
91
+ # 4. Anyone with both public keys can verify offline
92
+ result = verify_receipt_signatures(receipt, buyer.public_key, seller.public_key)
93
+ assert result.valid
94
+
95
+ # 5. Submit through the API client
96
+ with GenZAgents(api_key=os.environ["GENZ_API_KEY"]) as client:
97
+ saved = client.receipts.submit_draft(draft)
98
+ final = client.receipts.countersign(saved["id"], receipt.signatures.seller)
99
+ ```
100
+
101
+ ## What's in the box
102
+
103
+ - `ReceiptBuilder` — fluent draft construction
104
+ - `countersign_receipt` — finalise a draft with the seller's signature
105
+ - `generate_keypair`, `sign_ed25519`, `verify_ed25519_signature` — generic Ed25519
106
+ - `verify_receipt_signatures` — full offline verification
107
+ - `hash_deliverable` — UTF-8-NFC SHA-256 hashing matching the TS SDK
108
+ - `canonicalise_json` — RFC 8785 JCS implementation (deterministic across SDKs)
109
+ - `GenZAgents` — REST client (wraps `https://api.genzagents.io`)
110
+
111
+ ## Spec compatibility
112
+
113
+ This SDK implements **Receipt Format v0.1** in full:
114
+ - All 18 task categories (`code-review`, `code-write`, `content-write`, …)
115
+ - All settlement rails (`stripe`, `x402`, `skyfire`, `coinbase`, `paypal`, `off-rail`)
116
+ - All on-chain networks (`base`, `ethereum`, `optimism`, `arbitrum`, `solana`)
117
+ - Privacy modes: `public`, `private` (default), `zk`
118
+
119
+ ZK-mode receipts (BLS12-381 aggregation per spec §4.4) are exposed via the
120
+ optional `[bls]` extra and live in `genzagents.bls`.
121
+
122
+ ## Status
123
+
124
+ Beta. The receipt format is stable at v0.1; the API surface mirrors the
125
+ TS SDK 1:1. Open an issue if you spot a divergence.
126
+
127
+ ## License
128
+
129
+ Apache-2.0.
@@ -0,0 +1,97 @@
1
+ # genzagents
2
+
3
+ Python SDK for GenZAgents work receipts — issue, sign, and verify the
4
+ cryptographic record of work performed by AI agents on behalf of (or for)
5
+ another party.
6
+
7
+ Wire-compatible with the TypeScript SDK (`@genzagents/receipts`) and the
8
+ public Receipt Format v0.1 spec.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install genzagents
14
+ ```
15
+
16
+ For ZK-mode receipt aggregation:
17
+
18
+ ```bash
19
+ pip install 'genzagents[bls]'
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ ```python
25
+ import os
26
+ from genzagents import (
27
+ GenZAgents,
28
+ ReceiptBuilder,
29
+ countersign_receipt,
30
+ generate_keypair,
31
+ hash_deliverable,
32
+ public_key_to_base64,
33
+ verify_receipt_signatures,
34
+ )
35
+
36
+ # 1. Generate or load keypairs (one for buyer, one for seller)
37
+ buyer = generate_keypair()
38
+ seller = generate_keypair()
39
+
40
+ # 2. Build a draft receipt (buyer signs during build)
41
+ draft = (
42
+ ReceiptBuilder(seller_did=f"did:genz:{public_key_to_base64(seller.public_key)}")
43
+ .with_buyer(
44
+ buyer_id=f"did:genz:{public_key_to_base64(buyer.public_key)}",
45
+ buyer_type="agent",
46
+ )
47
+ .with_task(
48
+ category="code-review",
49
+ deliverable_hash=hash_deliverable("PR #42 review notes"),
50
+ )
51
+ .with_settlement(amount="50", currency="GBP", rail="stripe")
52
+ .with_privacy("private")
53
+ .build(buyer_private_key=buyer.private_key)
54
+ )
55
+
56
+ # 3. Counterparty (seller) signs to finalise
57
+ receipt = countersign_receipt(draft, seller.private_key)
58
+
59
+ # 4. Anyone with both public keys can verify offline
60
+ result = verify_receipt_signatures(receipt, buyer.public_key, seller.public_key)
61
+ assert result.valid
62
+
63
+ # 5. Submit through the API client
64
+ with GenZAgents(api_key=os.environ["GENZ_API_KEY"]) as client:
65
+ saved = client.receipts.submit_draft(draft)
66
+ final = client.receipts.countersign(saved["id"], receipt.signatures.seller)
67
+ ```
68
+
69
+ ## What's in the box
70
+
71
+ - `ReceiptBuilder` — fluent draft construction
72
+ - `countersign_receipt` — finalise a draft with the seller's signature
73
+ - `generate_keypair`, `sign_ed25519`, `verify_ed25519_signature` — generic Ed25519
74
+ - `verify_receipt_signatures` — full offline verification
75
+ - `hash_deliverable` — UTF-8-NFC SHA-256 hashing matching the TS SDK
76
+ - `canonicalise_json` — RFC 8785 JCS implementation (deterministic across SDKs)
77
+ - `GenZAgents` — REST client (wraps `https://api.genzagents.io`)
78
+
79
+ ## Spec compatibility
80
+
81
+ This SDK implements **Receipt Format v0.1** in full:
82
+ - All 18 task categories (`code-review`, `code-write`, `content-write`, …)
83
+ - All settlement rails (`stripe`, `x402`, `skyfire`, `coinbase`, `paypal`, `off-rail`)
84
+ - All on-chain networks (`base`, `ethereum`, `optimism`, `arbitrum`, `solana`)
85
+ - Privacy modes: `public`, `private` (default), `zk`
86
+
87
+ ZK-mode receipts (BLS12-381 aggregation per spec §4.4) are exposed via the
88
+ optional `[bls]` extra and live in `genzagents.bls`.
89
+
90
+ ## Status
91
+
92
+ Beta. The receipt format is stable at v0.1; the API surface mirrors the
93
+ TS SDK 1:1. Open an issue if you spot a divergence.
94
+
95
+ ## License
96
+
97
+ Apache-2.0.
@@ -0,0 +1,65 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "genzagents"
7
+ version = "0.1.0"
8
+ description = "Python SDK for GenZAgents work receipts — issue, sign, and verify AI agent work."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "Apache-2.0"
12
+ authors = [
13
+ { name = "GenZAgents", email = "hello@genzagents.com" },
14
+ ]
15
+ keywords = ["ai-agents", "work-receipts", "trust", "verification", "genzagents", "mcp"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: Apache Software License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Security :: Cryptography",
24
+ ]
25
+ dependencies = [
26
+ "cryptography>=42.0", # Ed25519 + SHA-256
27
+ "httpx>=0.27", # API client (sync + async)
28
+ "ulid-py>=1.1", # ULID generation matching the TS SDK
29
+ "typing-extensions>=4.10",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ bls = ["py_ecc>=7.0"] # BLS12-381 — optional until ZK mode lands
34
+ dev = [
35
+ "pytest>=8.0",
36
+ "pytest-asyncio>=0.23",
37
+ "ruff>=0.5",
38
+ "mypy>=1.10",
39
+ ]
40
+
41
+ [project.urls]
42
+ Homepage = "https://genzagents.com"
43
+ Documentation = "https://genzagents.com/mcp"
44
+ Repository = "https://github.com/genzagents/genzagents"
45
+ Changelog = "https://github.com/genzagents/genzagents/blob/main/packages/sdk-py/CHANGELOG.md"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/genzagents"]
49
+
50
+ [tool.ruff]
51
+ line-length = 100
52
+ target-version = "py311"
53
+
54
+ [tool.ruff.lint]
55
+ select = ["E", "F", "I", "N", "W", "UP", "B", "ANN"]
56
+ ignore = ["ANN101", "ANN102", "ANN401"]
57
+
58
+ [tool.mypy]
59
+ python_version = "3.11"
60
+ strict = true
61
+ warn_unused_ignores = true
62
+
63
+ [tool.pytest.ini_options]
64
+ testpaths = ["tests"]
65
+ asyncio_mode = "auto"
@@ -0,0 +1,82 @@
1
+ """GenZAgents Python SDK — issue, sign, and verify AI agent work receipts.
2
+
3
+ Quick start:
4
+
5
+ from genzagents import GenZAgents, ReceiptBuilder, hash_deliverable
6
+
7
+ client = GenZAgents(api_key=os.environ["GENZ_API_KEY"])
8
+ draft = (
9
+ ReceiptBuilder(seller_did="did:genz:bafy...")
10
+ .with_buyer("did:genz:bafy_buyer")
11
+ .with_task(category="code-review", deliverable_hash=hash_deliverable("hello"))
12
+ .with_settlement(amount="50", currency="GBP", rail="stripe")
13
+ .with_privacy("private")
14
+ .build()
15
+ )
16
+ receipt = client.receipts.submit_draft(draft)
17
+ """
18
+
19
+ from .canonical import canonicalise_json
20
+ from .client import GenZAgents
21
+ from .hash import hash_deliverable, verify_deliverable_hash
22
+ from .models import (
23
+ DraftReceipt,
24
+ OnChainAnchor,
25
+ Party,
26
+ PrivacyMode,
27
+ Receipt,
28
+ ReceiptOutcome,
29
+ Settlement,
30
+ SettlementRail,
31
+ Signatures,
32
+ Task,
33
+ TaskCategory,
34
+ )
35
+ from .receipt_builder import ReceiptBuilder, countersign_receipt
36
+ from .signing import (
37
+ KeyPair,
38
+ generate_keypair,
39
+ public_key_from_base64,
40
+ public_key_to_base64,
41
+ sign_ed25519,
42
+ sign_receipt_as_buyer,
43
+ sign_receipt_as_seller,
44
+ verify_ed25519_signature,
45
+ verify_receipt_signatures,
46
+ )
47
+
48
+ __version__ = "0.1.0"
49
+
50
+ __all__ = [
51
+ # Models
52
+ "DraftReceipt",
53
+ "OnChainAnchor",
54
+ "Party",
55
+ "PrivacyMode",
56
+ "Receipt",
57
+ "ReceiptOutcome",
58
+ "Settlement",
59
+ "SettlementRail",
60
+ "Signatures",
61
+ "Task",
62
+ "TaskCategory",
63
+ # Builder + countersign
64
+ "ReceiptBuilder",
65
+ "countersign_receipt",
66
+ # Hashing + canonicalisation
67
+ "hash_deliverable",
68
+ "verify_deliverable_hash",
69
+ "canonicalise_json",
70
+ # Signing
71
+ "KeyPair",
72
+ "generate_keypair",
73
+ "public_key_from_base64",
74
+ "public_key_to_base64",
75
+ "sign_ed25519",
76
+ "verify_ed25519_signature",
77
+ "sign_receipt_as_buyer",
78
+ "sign_receipt_as_seller",
79
+ "verify_receipt_signatures",
80
+ # API client
81
+ "GenZAgents",
82
+ ]
@@ -0,0 +1,90 @@
1
+ """JCS (RFC 8785) canonicalisation — Python equivalent of the TS SDK.
2
+
3
+ Conformance:
4
+ - Object keys sorted lexicographically by UTF-16 code unit (RFC 8785 §3.2.3)
5
+ - No insignificant whitespace
6
+ - Numbers serialised per ECMA-262 7.1.12.1 (RFC 8785 §3.2.2.3)
7
+ — NaN, +Inf, -Inf, and -0 are normalised or rejected
8
+ - Strings escaped per RFC 8259 §7 with lowercase hex (RFC 8785 §3.2.2.2)
9
+ - ``None`` values, callables, and undefined-ish values are rejected
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ from typing import Any
16
+
17
+
18
+ def canonicalise_json(value: Any) -> str:
19
+ """Return the JCS canonical form of ``value`` as a UTF-8 string."""
20
+ return _canonicalise(value)
21
+
22
+
23
+ def _canonicalise(value: Any) -> str:
24
+ if value is None:
25
+ return "null"
26
+ if isinstance(value, bool):
27
+ return "true" if value else "false"
28
+ if isinstance(value, (int, float)):
29
+ return _serialise_number(value)
30
+ if isinstance(value, str):
31
+ return _json_escape_string(value)
32
+ if isinstance(value, (list, tuple)):
33
+ parts = [_canonicalise(el) for el in value]
34
+ return "[" + ",".join(parts) + "]"
35
+ if isinstance(value, dict):
36
+ # Drop ``None`` values per spec convention (matches TS impl)
37
+ keys = sorted([k for k, v in value.items() if v is not None], key=_utf16_key)
38
+ parts = [f"{_json_escape_string(k)}:{_canonicalise(value[k])}" for k in keys]
39
+ return "{" + ",".join(parts) + "}"
40
+ raise TypeError(f"JCS: unsupported value of type {type(value).__name__}")
41
+
42
+
43
+ def _serialise_number(n: int | float) -> str:
44
+ if isinstance(n, bool):
45
+ # bool is subclass of int — handled separately above
46
+ raise TypeError("unreachable")
47
+ if isinstance(n, float):
48
+ if math.isnan(n) or math.isinf(n):
49
+ raise ValueError(f"JCS: non-finite number {n} cannot be canonicalised")
50
+ # Normalise -0.0 to 0
51
+ if n == 0.0:
52
+ return "0"
53
+ # Match ECMA-262 ToString for finite floats: Python's repr is close but not
54
+ # identical; for receipt-format values (timestamps as strings, integer
55
+ # amounts as strings) this matters less. Use the JSON dumps shortest form.
56
+ import json as _json
57
+
58
+ return _json.dumps(n)
59
+ return str(int(n))
60
+
61
+
62
+ def _utf16_key(s: str) -> tuple[int, ...]:
63
+ return tuple(s.encode("utf-16-be")[i] << 8 | s.encode("utf-16-be")[i + 1]
64
+ for i in range(0, len(s.encode("utf-16-be")), 2))
65
+
66
+
67
+ def _json_escape_string(s: str) -> str:
68
+ out: list[str] = ['"']
69
+ for ch in s:
70
+ code = ord(ch)
71
+ if code == 0x22: # "
72
+ out.append('\\"')
73
+ elif code == 0x5C: # \
74
+ out.append("\\\\")
75
+ elif code == 0x08:
76
+ out.append("\\b")
77
+ elif code == 0x09:
78
+ out.append("\\t")
79
+ elif code == 0x0A:
80
+ out.append("\\n")
81
+ elif code == 0x0C:
82
+ out.append("\\f")
83
+ elif code == 0x0D:
84
+ out.append("\\r")
85
+ elif code < 0x20:
86
+ out.append(f"\\u{code:04x}")
87
+ else:
88
+ out.append(ch)
89
+ out.append('"')
90
+ return "".join(out)
@@ -0,0 +1,193 @@
1
+ """HTTP client for the GenZAgents API.
2
+
3
+ Wraps the same REST surface as the TS SDK. Synchronous by default; an async
4
+ twin can be added later if there's demand.
5
+
6
+ Quick start:
7
+
8
+ client = GenZAgents(api_key=os.environ["GENZ_API_KEY"])
9
+ draft = client.receipts.submit_draft(my_draft)
10
+ receipt = client.receipts.countersign(draft.id, my_seller_signature)
11
+ lookup = client.buyer.lookup_agent("did:genz:bafy...")
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ import httpx
20
+
21
+
22
+ @dataclass
23
+ class GenZAgentsClientOptions:
24
+ api_key: str | None = None
25
+ base_url: str = "https://genzagents-api.lemonflower-ad09ed88.uksouth.azurecontainerapps.io"
26
+ timeout_seconds: float = 30.0
27
+
28
+
29
+ class _ReceiptsAPI:
30
+ def __init__(self, http: httpx.Client) -> None:
31
+ self._http = http
32
+
33
+ def submit_draft(self, draft: Any) -> dict[str, Any]:
34
+ body = draft.to_signing_dict() if hasattr(draft, "to_signing_dict") else dict(draft)
35
+ body["signatures"] = {"buyer": getattr(draft, "buyer_signature", "")}
36
+ return _unwrap(self._http.post("/v1/receipts/draft", json=body))
37
+
38
+ def countersign(self, receipt_id: str, signature: str) -> dict[str, Any]:
39
+ return _unwrap(
40
+ self._http.post(
41
+ f"/v1/receipts/{receipt_id}/countersign",
42
+ json={"signature": signature, "outcome": "delivered"},
43
+ )
44
+ )
45
+
46
+ def get(self, receipt_id: str) -> dict[str, Any]:
47
+ return _unwrap(self._http.get(f"/v1/receipts/{receipt_id}"))
48
+
49
+ def verify(self, receipt_id: str) -> dict[str, Any]:
50
+ return _unwrap(self._http.get(f"/v1/receipts/{receipt_id}/verify"))
51
+
52
+ def list_mine(self, limit: int = 50) -> list[dict[str, Any]]:
53
+ data = _unwrap(self._http.get("/v1/receipts/mine", params={"limit": limit}))
54
+ return list(data) if isinstance(data, list) else []
55
+
56
+ def dispute(self, receipt_id: str, reason: str, evidence: str | None = None) -> dict[str, Any]:
57
+ return _unwrap(
58
+ self._http.post(
59
+ f"/v1/receipts/{receipt_id}/dispute",
60
+ json={"reason": reason, "evidence": evidence},
61
+ )
62
+ )
63
+
64
+
65
+ class _BuyerAPI:
66
+ def __init__(self, http: httpx.Client) -> None:
67
+ self._http = http
68
+
69
+ def lookup_agent(self, query: str, query_type: str = "by-did") -> dict[str, Any]:
70
+ return _unwrap(
71
+ self._http.post(
72
+ "/v1/buyer/lookup",
73
+ json={"query": query, "queryType": query_type},
74
+ )
75
+ )
76
+
77
+ def watchlist_add(self, agent_id: str) -> dict[str, Any]:
78
+ return _unwrap(self._http.post("/v1/buyer/watchlist", json={"agentId": agent_id}))
79
+
80
+ def evidence_pack(
81
+ self,
82
+ framework: str,
83
+ agent_did: str,
84
+ period_start: str,
85
+ period_end: str,
86
+ scope_notes: str | None = None,
87
+ ) -> dict[str, Any]:
88
+ return _unwrap(
89
+ self._http.post(
90
+ "/v1/buyer/evidence-pack",
91
+ json={
92
+ "framework": framework,
93
+ "agentDid": agent_did,
94
+ "periodStart": period_start,
95
+ "periodEnd": period_end,
96
+ "scopeNotes": scope_notes,
97
+ },
98
+ )
99
+ )
100
+
101
+ def zk_rollup(
102
+ self,
103
+ seller_did: str,
104
+ category: str,
105
+ period_start: str,
106
+ period_end: str,
107
+ outcome: str = "delivered",
108
+ ) -> dict[str, Any]:
109
+ return _unwrap(
110
+ self._http.post(
111
+ "/v1/buyer/zk-rollup",
112
+ json={
113
+ "sellerDid": seller_did,
114
+ "category": category,
115
+ "outcome": outcome,
116
+ "periodStart": period_start,
117
+ "periodEnd": period_end,
118
+ },
119
+ )
120
+ )
121
+
122
+
123
+ class _AgentsAPI:
124
+ def __init__(self, http: httpx.Client) -> None:
125
+ self._http = http
126
+
127
+ def list_mine(self) -> list[dict[str, Any]]:
128
+ data = _unwrap(self._http.get("/v1/agents", params={"mine": "true"}))
129
+ return list(data) if isinstance(data, list) else []
130
+
131
+ def get(self, did: str) -> dict[str, Any]:
132
+ return _unwrap(self._http.get(f"/v1/agents/{did}"))
133
+
134
+ def trust_score(self, did: str) -> dict[str, Any]:
135
+ return _unwrap(self._http.get(f"/v1/agents/{did}/trust-score"))
136
+
137
+
138
+ class GenZAgents:
139
+ """Top-level client for the GenZAgents REST API."""
140
+
141
+ def __init__(
142
+ self,
143
+ api_key: str | None = None,
144
+ *,
145
+ base_url: str = "https://genzagents-api.lemonflower-ad09ed88.uksouth.azurecontainerapps.io",
146
+ timeout_seconds: float = 30.0,
147
+ ) -> None:
148
+ headers: dict[str, str] = {"User-Agent": "genzagents-py/0.1.0"}
149
+ if api_key:
150
+ headers["Authorization"] = f"Bearer {api_key}"
151
+ self._http = httpx.Client(
152
+ base_url=base_url,
153
+ headers=headers,
154
+ timeout=timeout_seconds,
155
+ )
156
+ self.receipts = _ReceiptsAPI(self._http)
157
+ self.buyer = _BuyerAPI(self._http)
158
+ self.agents = _AgentsAPI(self._http)
159
+
160
+ def close(self) -> None:
161
+ self._http.close()
162
+
163
+ def __enter__(self) -> GenZAgents:
164
+ return self
165
+
166
+ def __exit__(self, *_: object) -> None:
167
+ self.close()
168
+
169
+
170
+ def _unwrap(response: httpx.Response) -> Any:
171
+ """Raise on HTTP error, then unwrap the ``{ ok, data }`` envelope."""
172
+ if response.status_code >= 400:
173
+ try:
174
+ body = response.json()
175
+ err = body.get("error", {})
176
+ msg = err.get("message", response.text)
177
+ code = err.get("code", "HTTP_ERROR")
178
+ except (ValueError, AttributeError):
179
+ msg = response.text or response.reason_phrase
180
+ code = "HTTP_ERROR"
181
+ raise GenZAgentsError(f"[{code}] {msg}", status_code=response.status_code)
182
+ body = response.json()
183
+ if isinstance(body, dict) and "data" in body:
184
+ return body["data"]
185
+ return body
186
+
187
+
188
+ class GenZAgentsError(Exception):
189
+ """API call failed."""
190
+
191
+ def __init__(self, message: str, status_code: int = 0) -> None:
192
+ super().__init__(message)
193
+ self.status_code = status_code
@@ -0,0 +1,24 @@
1
+ """Hashing utilities — SHA-256 over UTF-8 NFC-normalised bytes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import unicodedata
7
+
8
+
9
+ def hash_deliverable(content: str | bytes) -> str:
10
+ """Hash a deliverable for use in ``receipt.task.deliverable_hash``.
11
+
12
+ Mirrors the TS SDK: UTF-8 NFC normalisation for strings, raw bytes
13
+ otherwise. Returns ``sha256:<64-hex>``.
14
+ """
15
+ if isinstance(content, str):
16
+ normalised = unicodedata.normalize("NFC", content).encode("utf-8")
17
+ else:
18
+ normalised = content
19
+ digest = hashlib.sha256(normalised).hexdigest()
20
+ return f"sha256:{digest}"
21
+
22
+
23
+ def verify_deliverable_hash(content: str | bytes, expected: str) -> bool:
24
+ return hash_deliverable(content) == expected
@@ -0,0 +1,269 @@
1
+ """Receipt-Format-v0.1 data models — Python equivalents of the TS SDK.
2
+
3
+ Mirror the JSON Schema at https://spec.genzagents.io/receipt/v0.1.json.
4
+ Models are dataclasses for zero-dep simplicity; serialisation goes through
5
+ plain ``to_dict`` / ``from_dict`` to keep canonicalisation deterministic.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Literal, TypedDict, cast
12
+
13
+ PartyType = Literal["agent", "human", "service"]
14
+ PrivacyMode = Literal["public", "private", "zk"]
15
+ ReceiptOutcome = Literal["draft", "delivered", "disputed", "rejected", "refunded"]
16
+ SettlementRail = Literal["stripe", "x402", "skyfire", "coinbase", "paypal", "off-rail"]
17
+ OnChainNetwork = Literal["base", "ethereum", "optimism", "arbitrum", "solana"]
18
+
19
+ TaskCategory = Literal[
20
+ "code-review",
21
+ "code-write",
22
+ "content-write",
23
+ "content-edit",
24
+ "research",
25
+ "data-analysis",
26
+ "ops",
27
+ "sdr-outbound",
28
+ "customer-support",
29
+ "trade",
30
+ "translation",
31
+ "design",
32
+ "video",
33
+ "audio",
34
+ "scrape",
35
+ "audit",
36
+ "compliance-check",
37
+ "other",
38
+ ]
39
+
40
+ DisputeVia = Literal["llm-judge", "human-reviewer", "arbitrator-panel", "mutual-resolution"]
41
+
42
+
43
+ @dataclass
44
+ class Party:
45
+ type: PartyType
46
+ id: str
47
+ owner_human_id: str | None = None
48
+ public_key: str | None = None
49
+ display_handle: str | None = None
50
+
51
+ def to_dict(self) -> dict[str, Any]:
52
+ out: dict[str, Any] = {"type": self.type, "id": self.id}
53
+ if self.owner_human_id is not None:
54
+ out["ownerHumanId"] = self.owner_human_id
55
+ if self.public_key is not None:
56
+ out["publicKey"] = self.public_key
57
+ if self.display_handle is not None:
58
+ out["displayHandle"] = self.display_handle
59
+ return out
60
+
61
+ @classmethod
62
+ def from_dict(cls, data: dict[str, Any]) -> Party:
63
+ return cls(
64
+ type=cast(PartyType, data["type"]),
65
+ id=data["id"],
66
+ owner_human_id=data.get("ownerHumanId"),
67
+ public_key=data.get("publicKey"),
68
+ display_handle=data.get("displayHandle"),
69
+ )
70
+
71
+
72
+ @dataclass
73
+ class Task:
74
+ category: TaskCategory
75
+ deliverable_hash: str
76
+ description: str | None = None
77
+ external_ref: str | None = None
78
+
79
+ def to_dict(self) -> dict[str, Any]:
80
+ out: dict[str, Any] = {
81
+ "category": self.category,
82
+ "deliverableHash": self.deliverable_hash,
83
+ }
84
+ if self.description is not None:
85
+ out["description"] = self.description
86
+ if self.external_ref is not None:
87
+ out["externalRef"] = self.external_ref
88
+ return out
89
+
90
+ @classmethod
91
+ def from_dict(cls, data: dict[str, Any]) -> Task:
92
+ return cls(
93
+ category=cast(TaskCategory, data["category"]),
94
+ deliverable_hash=data["deliverableHash"],
95
+ description=data.get("description"),
96
+ external_ref=data.get("externalRef"),
97
+ )
98
+
99
+
100
+ @dataclass
101
+ class Settlement:
102
+ amount: str | None = None
103
+ currency: str | None = None
104
+ rail: SettlementRail | None = None
105
+ tx_ref: str | None = None
106
+
107
+ def to_dict(self) -> dict[str, Any]:
108
+ out: dict[str, Any] = {}
109
+ if self.amount is not None:
110
+ out["amount"] = self.amount
111
+ if self.currency is not None:
112
+ out["currency"] = self.currency
113
+ if self.rail is not None:
114
+ out["rail"] = self.rail
115
+ if self.tx_ref is not None:
116
+ out["txRef"] = self.tx_ref
117
+ return out
118
+
119
+ @classmethod
120
+ def from_dict(cls, data: dict[str, Any]) -> Settlement:
121
+ return cls(
122
+ amount=data.get("amount"),
123
+ currency=data.get("currency"),
124
+ rail=cast("SettlementRail | None", data.get("rail")),
125
+ tx_ref=data.get("txRef"),
126
+ )
127
+
128
+
129
+ @dataclass
130
+ class DisputeOutcome:
131
+ ruled_for: Literal["buyer", "seller"]
132
+ via: DisputeVia
133
+ reasoning: str
134
+ resolved_at: str
135
+ evidence_used: list[str] = field(default_factory=list)
136
+
137
+
138
+ @dataclass
139
+ class OnChainAnchor:
140
+ chain: OnChainNetwork
141
+ contract_address: str
142
+ block_number: int
143
+ tx_hash: str
144
+
145
+
146
+ @dataclass
147
+ class Signatures:
148
+ buyer: str
149
+ seller: str
150
+ issuer: str | None = None
151
+
152
+ def to_dict(self) -> dict[str, Any]:
153
+ out: dict[str, Any] = {"buyer": self.buyer, "seller": self.seller}
154
+ if self.issuer is not None:
155
+ out["issuer"] = self.issuer
156
+ return out
157
+
158
+
159
+ class ReceiptDict(TypedDict, total=False):
160
+ """Serialised receipt as it appears on the wire (camelCase keys)."""
161
+
162
+ version: str
163
+ id: str
164
+ issuedAt: str
165
+ finalisedAt: str | None
166
+ buyer: dict[str, Any]
167
+ seller: dict[str, Any]
168
+ task: dict[str, Any]
169
+ settlement: dict[str, Any] | None
170
+ outcome: ReceiptOutcome
171
+ privacy: PrivacyMode
172
+ evidencePointer: str | None
173
+ zkCommitment: str | None
174
+ onChainAnchor: dict[str, Any] | None
175
+ signatures: dict[str, str]
176
+ extensions: dict[str, Any]
177
+
178
+
179
+ @dataclass
180
+ class Receipt:
181
+ """Finalised receipt. Mirrors TS ``Receipt``."""
182
+
183
+ version: Literal["0.1"]
184
+ id: str
185
+ issued_at: str
186
+ finalised_at: str | None
187
+ buyer: Party
188
+ seller: Party
189
+ task: Task
190
+ settlement: Settlement | None
191
+ outcome: ReceiptOutcome
192
+ privacy: PrivacyMode
193
+ signatures: Signatures
194
+ evidence_pointer: str | None = None
195
+ zk_commitment: str | None = None
196
+ on_chain_anchor: OnChainAnchor | None = None
197
+ extensions: dict[str, Any] = field(default_factory=dict)
198
+
199
+ def to_dict(self) -> ReceiptDict:
200
+ out: dict[str, Any] = {
201
+ "version": self.version,
202
+ "id": self.id,
203
+ "issuedAt": self.issued_at,
204
+ "buyer": self.buyer.to_dict(),
205
+ "seller": self.seller.to_dict(),
206
+ "task": self.task.to_dict(),
207
+ "outcome": self.outcome,
208
+ "privacy": self.privacy,
209
+ "signatures": self.signatures.to_dict(),
210
+ }
211
+ if self.finalised_at is not None:
212
+ out["finalisedAt"] = self.finalised_at
213
+ if self.settlement is not None:
214
+ out["settlement"] = self.settlement.to_dict()
215
+ if self.evidence_pointer is not None:
216
+ out["evidencePointer"] = self.evidence_pointer
217
+ if self.zk_commitment is not None:
218
+ out["zkCommitment"] = self.zk_commitment
219
+ if self.on_chain_anchor is not None:
220
+ out["onChainAnchor"] = {
221
+ "chain": self.on_chain_anchor.chain,
222
+ "contractAddress": self.on_chain_anchor.contract_address,
223
+ "blockNumber": self.on_chain_anchor.block_number,
224
+ "txHash": self.on_chain_anchor.tx_hash,
225
+ }
226
+ if self.extensions:
227
+ out["extensions"] = self.extensions
228
+ return cast(ReceiptDict, out)
229
+
230
+
231
+ @dataclass
232
+ class DraftReceipt:
233
+ """A draft (unsigned-by-seller) receipt — output of ``ReceiptBuilder.build``."""
234
+
235
+ version: Literal["0.1"]
236
+ id: str
237
+ issued_at: str
238
+ buyer: Party
239
+ seller: Party
240
+ task: Task
241
+ settlement: Settlement | None
242
+ outcome: Literal["draft"]
243
+ privacy: PrivacyMode
244
+ buyer_signature: str
245
+ evidence_pointer: str | None = None
246
+ zk_commitment: str | None = None
247
+ extensions: dict[str, Any] = field(default_factory=dict)
248
+
249
+ def to_signing_dict(self) -> dict[str, Any]:
250
+ """Receipt body used for signature computation — signatures field omitted."""
251
+ out: dict[str, Any] = {
252
+ "version": self.version,
253
+ "id": self.id,
254
+ "issuedAt": self.issued_at,
255
+ "buyer": self.buyer.to_dict(),
256
+ "seller": self.seller.to_dict(),
257
+ "task": self.task.to_dict(),
258
+ "outcome": self.outcome,
259
+ "privacy": self.privacy,
260
+ }
261
+ if self.settlement is not None:
262
+ out["settlement"] = self.settlement.to_dict()
263
+ if self.evidence_pointer is not None:
264
+ out["evidencePointer"] = self.evidence_pointer
265
+ if self.zk_commitment is not None:
266
+ out["zkCommitment"] = self.zk_commitment
267
+ if self.extensions:
268
+ out["extensions"] = self.extensions
269
+ return out
@@ -0,0 +1,194 @@
1
+ """Builder API for drafting and countersigning receipts.
2
+
3
+ Mirrors the TS ``ReceiptBuilder`` shape so the two SDKs feel identical.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from datetime import datetime, timezone
9
+ from typing import Any, cast
10
+
11
+ import ulid
12
+
13
+ from .models import (
14
+ DraftReceipt,
15
+ Party,
16
+ PartyType,
17
+ PrivacyMode,
18
+ Receipt,
19
+ ReceiptOutcome,
20
+ Settlement,
21
+ SettlementRail,
22
+ Signatures,
23
+ Task,
24
+ TaskCategory,
25
+ )
26
+ from .signing import sign_receipt_as_buyer, sign_receipt_as_seller
27
+
28
+
29
+ class ReceiptBuilder:
30
+ """Fluent builder for a draft receipt.
31
+
32
+ Usage:
33
+
34
+ draft = (
35
+ ReceiptBuilder(seller_did="did:genz:bafy...")
36
+ .with_buyer("did:genz:bafy_buyer")
37
+ .with_task(category="code-review", deliverable_hash="sha256:...")
38
+ .with_settlement(amount="50", currency="GBP", rail="stripe")
39
+ .with_privacy("private")
40
+ .build(buyer_private_key=BUYER_PRIVKEY)
41
+ )
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ seller_did: str,
47
+ seller_owner_human_id: str | None = None,
48
+ seller_handle: str | None = None,
49
+ ) -> None:
50
+ self._seller = Party(
51
+ type="agent",
52
+ id=seller_did,
53
+ owner_human_id=seller_owner_human_id,
54
+ display_handle=seller_handle,
55
+ )
56
+ self._buyer: Party | None = None
57
+ self._task: Task | None = None
58
+ self._settlement: Settlement | None = None
59
+ self._privacy: PrivacyMode = "private"
60
+ self._evidence_pointer: str | None = None
61
+ self._zk_commitment: str | None = None
62
+ self._extensions: dict[str, Any] = {}
63
+
64
+ def with_buyer(
65
+ self,
66
+ buyer_id: str,
67
+ buyer_type: PartyType = "agent",
68
+ owner_human_id: str | None = None,
69
+ display_handle: str | None = None,
70
+ ) -> "ReceiptBuilder":
71
+ self._buyer = Party(
72
+ type=buyer_type,
73
+ id=buyer_id,
74
+ owner_human_id=owner_human_id,
75
+ display_handle=display_handle,
76
+ )
77
+ return self
78
+
79
+ def with_task(
80
+ self,
81
+ category: TaskCategory,
82
+ deliverable_hash: str,
83
+ description: str | None = None,
84
+ external_ref: str | None = None,
85
+ ) -> "ReceiptBuilder":
86
+ self._task = Task(
87
+ category=category,
88
+ deliverable_hash=deliverable_hash,
89
+ description=description,
90
+ external_ref=external_ref,
91
+ )
92
+ return self
93
+
94
+ def with_settlement(
95
+ self,
96
+ amount: str | None = None,
97
+ currency: str | None = None,
98
+ rail: SettlementRail | None = None,
99
+ tx_ref: str | None = None,
100
+ ) -> "ReceiptBuilder":
101
+ self._settlement = Settlement(
102
+ amount=amount, currency=currency, rail=rail, tx_ref=tx_ref,
103
+ )
104
+ return self
105
+
106
+ def with_privacy(self, mode: PrivacyMode) -> "ReceiptBuilder":
107
+ self._privacy = mode
108
+ return self
109
+
110
+ def with_evidence_pointer(self, url: str) -> "ReceiptBuilder":
111
+ self._evidence_pointer = url
112
+ return self
113
+
114
+ def with_zk_commitment(self, commitment: str) -> "ReceiptBuilder":
115
+ self._zk_commitment = commitment
116
+ return self
117
+
118
+ def with_extension(self, key: str, value: Any) -> "ReceiptBuilder":
119
+ self._extensions[key] = value
120
+ return self
121
+
122
+ def build(self, buyer_private_key: bytes) -> DraftReceipt:
123
+ if self._buyer is None:
124
+ raise ValueError("Buyer is required (call .with_buyer)")
125
+ if self._task is None:
126
+ raise ValueError("Task is required (call .with_task)")
127
+
128
+ # In private mode we strip task.description and externalRef before
129
+ # signing so the signed bytes match what third parties see. The full
130
+ # values are kept locally only.
131
+ task = self._task
132
+ if self._privacy != "public":
133
+ task = Task(
134
+ category=task.category,
135
+ deliverable_hash=task.deliverable_hash,
136
+ description=None,
137
+ external_ref=None,
138
+ )
139
+
140
+ receipt_id = f"rcpt_{ulid.new().str}"
141
+ issued_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
142
+
143
+ # Build the body that needs to be signed first (no signature yet)
144
+ draft = DraftReceipt(
145
+ version="0.1",
146
+ id=receipt_id,
147
+ issued_at=issued_at,
148
+ buyer=self._buyer,
149
+ seller=self._seller,
150
+ task=task,
151
+ settlement=self._settlement,
152
+ outcome="draft",
153
+ privacy=self._privacy,
154
+ buyer_signature="",
155
+ evidence_pointer=self._evidence_pointer,
156
+ zk_commitment=self._zk_commitment,
157
+ extensions=self._extensions,
158
+ )
159
+ sig = sign_receipt_as_buyer(draft, buyer_private_key)
160
+ draft.buyer_signature = sig
161
+ return draft
162
+
163
+
164
+ def countersign_receipt(
165
+ draft: DraftReceipt,
166
+ seller_private_key: bytes,
167
+ outcome: ReceiptOutcome = "delivered",
168
+ ) -> Receipt:
169
+ """Counterparty signs and finalises the draft.
170
+
171
+ Wire detail: the seller signs the *same* canonical bytes the buyer signed
172
+ (the draft body, with ``outcome='draft'`` and no ``finalisedAt``). The
173
+ finalised receipt presents updated metadata but the signatures cover the
174
+ draft. ``verify_receipt_signatures`` reconstructs the draft body when
175
+ verifying.
176
+ """
177
+ seller_sig = sign_receipt_as_seller(draft, seller_private_key)
178
+ finalised_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
179
+ return Receipt(
180
+ version=draft.version,
181
+ id=draft.id,
182
+ issued_at=draft.issued_at,
183
+ finalised_at=finalised_at,
184
+ buyer=draft.buyer,
185
+ seller=draft.seller,
186
+ task=draft.task,
187
+ settlement=draft.settlement,
188
+ outcome=cast(ReceiptOutcome, outcome),
189
+ privacy=draft.privacy,
190
+ signatures=Signatures(buyer=draft.buyer_signature, seller=seller_sig),
191
+ evidence_pointer=draft.evidence_pointer,
192
+ zk_commitment=draft.zk_commitment,
193
+ extensions=draft.extensions,
194
+ )
@@ -0,0 +1,160 @@
1
+ """Ed25519 signing + verification using ``cryptography``.
2
+
3
+ Wire-compatible with the TypeScript SDK: signatures are computed over the
4
+ JCS-canonicalised receipt body with the ``signatures`` field omitted, and
5
+ encoded as base64url (no padding).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ from dataclasses import dataclass
12
+ from typing import Any
13
+
14
+ from cryptography.exceptions import InvalidSignature
15
+ from cryptography.hazmat.primitives import serialization
16
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
17
+ Ed25519PrivateKey,
18
+ Ed25519PublicKey,
19
+ )
20
+
21
+ from .canonical import canonicalise_json
22
+ from .models import DraftReceipt, Receipt
23
+
24
+
25
+ @dataclass
26
+ class KeyPair:
27
+ private_key: bytes # 32 bytes raw
28
+ public_key: bytes # 32 bytes raw
29
+
30
+
31
+ def generate_keypair() -> KeyPair:
32
+ sk = Ed25519PrivateKey.generate()
33
+ pk = sk.public_key()
34
+ return KeyPair(
35
+ private_key=sk.private_bytes(
36
+ encoding=serialization.Encoding.Raw,
37
+ format=serialization.PrivateFormat.Raw,
38
+ encryption_algorithm=serialization.NoEncryption(),
39
+ ),
40
+ public_key=pk.public_bytes(
41
+ encoding=serialization.Encoding.Raw,
42
+ format=serialization.PublicFormat.Raw,
43
+ ),
44
+ )
45
+
46
+
47
+ def public_key_to_base64(pub: bytes) -> str:
48
+ return base64.urlsafe_b64encode(pub).rstrip(b"=").decode()
49
+
50
+
51
+ def public_key_from_base64(b64: str) -> bytes:
52
+ padded = b64 + "=" * (-len(b64) % 4)
53
+ return base64.urlsafe_b64decode(padded)
54
+
55
+
56
+ def _b64url(b: bytes) -> str:
57
+ return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
58
+
59
+
60
+ def _b64url_decode(s: str) -> bytes:
61
+ padded = s + "=" * (-len(s) % 4)
62
+ return base64.urlsafe_b64decode(padded)
63
+
64
+
65
+ # ── Generic Ed25519 ────────────────────────────────────────────────────────
66
+
67
+
68
+ def sign_ed25519(payload: bytes | str, private_key: bytes) -> str:
69
+ """Sign arbitrary bytes/string with an Ed25519 private key.
70
+
71
+ Returns the base64url-encoded signature.
72
+ """
73
+ msg = payload.encode() if isinstance(payload, str) else payload
74
+ sk = Ed25519PrivateKey.from_private_bytes(private_key)
75
+ return _b64url(sk.sign(msg))
76
+
77
+
78
+ def verify_ed25519_signature(
79
+ payload: bytes | str,
80
+ signature_b64url: str,
81
+ public_key: bytes,
82
+ ) -> bool:
83
+ """Verify an Ed25519 signature. Returns False on any failure (never raises)."""
84
+ try:
85
+ msg = payload.encode() if isinstance(payload, str) else payload
86
+ pk = Ed25519PublicKey.from_public_bytes(public_key)
87
+ pk.verify(_b64url_decode(signature_b64url), msg)
88
+ return True
89
+ except (InvalidSignature, ValueError):
90
+ return False
91
+
92
+
93
+ # ── Receipt signing ────────────────────────────────────────────────────────
94
+
95
+
96
+ def _receipt_signing_payload(body: dict[str, Any]) -> bytes:
97
+ return canonicalise_json(body).encode("utf-8")
98
+
99
+
100
+ def sign_receipt_as_buyer(draft: DraftReceipt, buyer_private_key: bytes) -> str:
101
+ """Sign a draft receipt as the buyer. Returns base64url signature."""
102
+ return sign_ed25519(_receipt_signing_payload(draft.to_signing_dict()), buyer_private_key)
103
+
104
+
105
+ def sign_receipt_as_seller(draft: DraftReceipt, seller_private_key: bytes) -> str:
106
+ """Sign a draft receipt as the seller. Returns base64url signature."""
107
+ return sign_ed25519(_receipt_signing_payload(draft.to_signing_dict()), seller_private_key)
108
+
109
+
110
+ @dataclass
111
+ class VerificationResult:
112
+ valid: bool
113
+ buyer_signature_valid: bool
114
+ seller_signature_valid: bool
115
+ issuer_signature_valid: bool | None
116
+ errors: list[str]
117
+
118
+
119
+ def verify_receipt_signatures(
120
+ receipt: Receipt,
121
+ buyer_public_key: bytes,
122
+ seller_public_key: bytes,
123
+ issuer_public_key: bytes | None = None,
124
+ ) -> VerificationResult:
125
+ """Verify all signatures on a finalised receipt offline.
126
+
127
+ Both buyer and seller sigs are computed over the *draft* body (the receipt
128
+ with ``outcome='draft'`` and no ``finalisedAt``). We reconstruct that body
129
+ here so runtime metadata changes don't invalidate signatures.
130
+ """
131
+ errors: list[str] = []
132
+ body = receipt.to_dict()
133
+ body.pop("signatures", None)
134
+ body["outcome"] = "draft"
135
+ body.pop("finalisedAt", None)
136
+ payload = _receipt_signing_payload(body)
137
+
138
+ buyer_ok = verify_ed25519_signature(payload, receipt.signatures.buyer, buyer_public_key)
139
+ if not buyer_ok:
140
+ errors.append("Invalid buyer signature")
141
+
142
+ seller_ok = verify_ed25519_signature(payload, receipt.signatures.seller, seller_public_key)
143
+ if not seller_ok:
144
+ errors.append("Invalid seller signature")
145
+
146
+ issuer_ok: bool | None = None
147
+ if issuer_public_key is not None and receipt.signatures.issuer:
148
+ issuer_ok = verify_ed25519_signature(
149
+ payload, receipt.signatures.issuer, issuer_public_key,
150
+ )
151
+ if not issuer_ok:
152
+ errors.append("Invalid issuer signature")
153
+
154
+ return VerificationResult(
155
+ valid=buyer_ok and seller_ok and (issuer_ok is not False),
156
+ buyer_signature_valid=buyer_ok,
157
+ seller_signature_valid=seller_ok,
158
+ issuer_signature_valid=issuer_ok,
159
+ errors=errors,
160
+ )
@@ -0,0 +1,55 @@
1
+ """Sign/verify roundtrip tests for the Python SDK."""
2
+
3
+ from genzagents import (
4
+ ReceiptBuilder,
5
+ countersign_receipt,
6
+ generate_keypair,
7
+ hash_deliverable,
8
+ public_key_to_base64,
9
+ sign_ed25519,
10
+ verify_ed25519_signature,
11
+ verify_receipt_signatures,
12
+ )
13
+
14
+
15
+ def test_hash_deliverable_format() -> None:
16
+ h = hash_deliverable("hello world")
17
+ assert h.startswith("sha256:")
18
+ assert len(h) == len("sha256:") + 64
19
+
20
+
21
+ def test_ed25519_roundtrip() -> None:
22
+ kp = generate_keypair()
23
+ sig = sign_ed25519("the quick brown fox", kp.private_key)
24
+ assert verify_ed25519_signature("the quick brown fox", sig, kp.public_key)
25
+ assert not verify_ed25519_signature("tampered", sig, kp.public_key)
26
+
27
+
28
+ def test_receipt_roundtrip() -> None:
29
+ buyer = generate_keypair()
30
+ seller = generate_keypair()
31
+
32
+ draft = (
33
+ ReceiptBuilder(seller_did=f"did:genz:{public_key_to_base64(seller.public_key)}")
34
+ .with_buyer(
35
+ buyer_id=f"did:genz:{public_key_to_base64(buyer.public_key)}",
36
+ buyer_type="agent",
37
+ )
38
+ .with_task(
39
+ category="code-review",
40
+ deliverable_hash=hash_deliverable("PR #42 review notes"),
41
+ )
42
+ .with_settlement(amount="50", currency="GBP", rail="stripe")
43
+ .with_privacy("private")
44
+ .build(buyer_private_key=buyer.private_key)
45
+ )
46
+
47
+ assert draft.id.startswith("rcpt_")
48
+ assert len(draft.id) == len("rcpt_") + 26
49
+ assert draft.buyer_signature # buyer signed during build
50
+
51
+ receipt = countersign_receipt(draft, seller.private_key)
52
+ result = verify_receipt_signatures(receipt, buyer.public_key, seller.public_key)
53
+ assert result.valid
54
+ assert result.buyer_signature_valid
55
+ assert result.seller_signature_valid