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.
- genzagents-0.1.0/.gitignore +18 -0
- genzagents-0.1.0/PKG-INFO +129 -0
- genzagents-0.1.0/README.md +97 -0
- genzagents-0.1.0/pyproject.toml +65 -0
- genzagents-0.1.0/src/genzagents/__init__.py +82 -0
- genzagents-0.1.0/src/genzagents/canonical.py +90 -0
- genzagents-0.1.0/src/genzagents/client.py +193 -0
- genzagents-0.1.0/src/genzagents/hash.py +24 -0
- genzagents-0.1.0/src/genzagents/models.py +269 -0
- genzagents-0.1.0/src/genzagents/receipt_builder.py +194 -0
- genzagents-0.1.0/src/genzagents/signing.py +160 -0
- genzagents-0.1.0/tests/test_sign_verify.py +55 -0
|
@@ -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
|