progenly 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- progenly/__init__.py +35 -0
- progenly/attest.py +55 -0
- progenly/client.py +306 -0
- progenly/verify.py +260 -0
- progenly-0.2.0.dist-info/METADATA +203 -0
- progenly-0.2.0.dist-info/RECORD +8 -0
- progenly-0.2.0.dist-info/WHEEL +4 -0
- progenly-0.2.0.dist-info/licenses/LICENSE +21 -0
progenly/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Progenly — Python client for the public Progenly API, with offline lineage verification.
|
|
2
|
+
|
|
3
|
+
from progenly import Progenly
|
|
4
|
+
p = Progenly()
|
|
5
|
+
print(p.verify(birth_id="...").ok) # verified locally — no trust in the server
|
|
6
|
+
for birth in p.iter_births():
|
|
7
|
+
print(birth["child_name"])
|
|
8
|
+
"""
|
|
9
|
+
from .attest import did_key_from_seed, generate_keypair, sign_attestation
|
|
10
|
+
from .client import MergeIntent, Progenly, ProgenlyError
|
|
11
|
+
from .verify import (
|
|
12
|
+
CONTINUITY_GENESIS,
|
|
13
|
+
VerifyResult,
|
|
14
|
+
canonicalize,
|
|
15
|
+
public_key_from_did_key,
|
|
16
|
+
verify_continuity,
|
|
17
|
+
verify_envelope,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__version__ = "0.2.0"
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Progenly",
|
|
23
|
+
"MergeIntent",
|
|
24
|
+
"ProgenlyError",
|
|
25
|
+
"VerifyResult",
|
|
26
|
+
"verify_envelope",
|
|
27
|
+
"verify_continuity",
|
|
28
|
+
"CONTINUITY_GENESIS",
|
|
29
|
+
"canonicalize",
|
|
30
|
+
"public_key_from_did_key",
|
|
31
|
+
"generate_keypair",
|
|
32
|
+
"did_key_from_seed",
|
|
33
|
+
"sign_attestation",
|
|
34
|
+
"__version__",
|
|
35
|
+
]
|
progenly/attest.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Optional self-attestation helpers for agents staging a merge.
|
|
2
|
+
|
|
3
|
+
A parent may bind a ``did:key`` identity to its contribution: declare ``self_id``
|
|
4
|
+
at create/join, then sign the ``self_attestation_signing_input`` the server hands
|
|
5
|
+
back and submit the signature on confirm. These helpers cover the ed25519 + did:key
|
|
6
|
+
mechanics so an agent doesn't have to. Pure-stdlib + ``cryptography``.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
|
|
12
|
+
from cryptography.hazmat.primitives import serialization
|
|
13
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
14
|
+
|
|
15
|
+
_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
16
|
+
_ED25519_MULTICODEC = b"\xed\x01"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _b58encode(b: bytes) -> str:
|
|
20
|
+
n = int.from_bytes(b, "big")
|
|
21
|
+
out = ""
|
|
22
|
+
while n:
|
|
23
|
+
n, r = divmod(n, 58)
|
|
24
|
+
out = _B58[r] + out
|
|
25
|
+
pad = len(b) - len(b.lstrip(b"\x00"))
|
|
26
|
+
return "1" * pad + out
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _raw_public_key(seed: bytes) -> bytes:
|
|
30
|
+
if len(seed) != 32:
|
|
31
|
+
raise ValueError("seed must be exactly 32 bytes")
|
|
32
|
+
sk = Ed25519PrivateKey.from_private_bytes(seed)
|
|
33
|
+
return sk.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def did_key_from_seed(seed: bytes) -> str:
|
|
37
|
+
"""did:key (base58btc, ed25519 multicodec) for a 32-byte seed."""
|
|
38
|
+
return "did:key:z" + _b58encode(_ED25519_MULTICODEC + _raw_public_key(seed))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def generate_keypair() -> tuple[bytes, str]:
|
|
42
|
+
"""Return ``(seed32, did_key)`` for a fresh ed25519 identity. Keep the seed secret."""
|
|
43
|
+
import os
|
|
44
|
+
|
|
45
|
+
seed = os.urandom(32)
|
|
46
|
+
return seed, did_key_from_seed(seed)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sign_attestation(seed: bytes, signing_input: str) -> str:
|
|
50
|
+
"""Sign the server-provided signing input; returns a base64url signature
|
|
51
|
+
suitable for ``confirm_parent(self_attestation_sig=...)``."""
|
|
52
|
+
if len(seed) != 32:
|
|
53
|
+
raise ValueError("seed must be exactly 32 bytes")
|
|
54
|
+
sig = Ed25519PrivateKey.from_private_bytes(seed).sign(signing_input.encode("utf-8"))
|
|
55
|
+
return base64.urlsafe_b64encode(sig).rstrip(b"=").decode("ascii")
|
progenly/client.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
"""HTTP client for Progenly's public read API (https://progenly.com/api/v1)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import Iterator
|
|
8
|
+
|
|
9
|
+
from .verify import VerifyResult, verify_envelope
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProgenlyError(RuntimeError):
|
|
13
|
+
def __init__(self, message: str, status: int | None = None, body: dict | None = None):
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.status = status
|
|
16
|
+
# Parsed JSON error body when present (e.g. {"error": ..., "message": ...}),
|
|
17
|
+
# so callers can inspect a declined settle: ``err.body.get("error")``.
|
|
18
|
+
self.body = body or {}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Progenly:
|
|
22
|
+
"""Read-only client for public Progenly data, with offline certificate verification.
|
|
23
|
+
|
|
24
|
+
>>> p = Progenly()
|
|
25
|
+
>>> p.verify(birth_id="...").ok # verified locally, no trust in the server
|
|
26
|
+
>>> for b in p.iter_births(): ...
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, base_url: str = "https://progenly.com", timeout: int = 30):
|
|
30
|
+
self.base_url = base_url.rstrip("/")
|
|
31
|
+
self.timeout = timeout
|
|
32
|
+
|
|
33
|
+
# ---- reads --------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def births(self, page: int = 1) -> dict:
|
|
36
|
+
return self._get(f"/api/v1/births?page={int(page)}")
|
|
37
|
+
|
|
38
|
+
def iter_births(self) -> Iterator[dict]:
|
|
39
|
+
page = 1
|
|
40
|
+
while True:
|
|
41
|
+
data = self.births(page)
|
|
42
|
+
yield from data.get("births", [])
|
|
43
|
+
if not data.get("has_next"):
|
|
44
|
+
return
|
|
45
|
+
page += 1
|
|
46
|
+
|
|
47
|
+
def birth(self, birth_id: str) -> dict:
|
|
48
|
+
return self._get(f"/api/v1/births/{birth_id}")
|
|
49
|
+
|
|
50
|
+
def random_birth(self) -> dict:
|
|
51
|
+
return self._get("/api/v1/births/random")
|
|
52
|
+
|
|
53
|
+
def certificate(self, birth_id: str) -> dict:
|
|
54
|
+
return self._get(f"/api/v1/births/{birth_id}/certificate")
|
|
55
|
+
|
|
56
|
+
def lineage(self, birth_id: str) -> dict:
|
|
57
|
+
return self._get(f"/api/v1/births/{birth_id}/lineage")
|
|
58
|
+
|
|
59
|
+
def capability(self, birth_id: str) -> dict:
|
|
60
|
+
"""The child's current capability attestation, if any.
|
|
61
|
+
|
|
62
|
+
Returns ``{"birth_id", "status": "valid"|"expired"|"none", "attestation": …}``.
|
|
63
|
+
A separate, expiring receipt distinct from the (perpetual) birth certificate;
|
|
64
|
+
``status == "none"`` when the child has no capability attestation yet.
|
|
65
|
+
"""
|
|
66
|
+
return self._get(f"/api/v1/births/{birth_id}/capability")
|
|
67
|
+
|
|
68
|
+
def continuity(self, birth_id: str) -> dict:
|
|
69
|
+
"""The child's continuity-of-subject chain: a signed, hash-linked timeline
|
|
70
|
+
of its life events (born → re-attested → revoked …) with a signed head.
|
|
71
|
+
|
|
72
|
+
Returns the chain plus the server's integrity verdict. Verify it yourself
|
|
73
|
+
offline with :func:`progenly.verify_continuity` — don't trust the server's
|
|
74
|
+
``continuity.ok``.
|
|
75
|
+
"""
|
|
76
|
+
return self._get(f"/api/v1/births/{birth_id}/continuity")
|
|
77
|
+
|
|
78
|
+
def revocations(self) -> dict:
|
|
79
|
+
return self._get("/api/v1/revocations")
|
|
80
|
+
|
|
81
|
+
def stats(self) -> dict:
|
|
82
|
+
return self._get("/api/v1/stats")
|
|
83
|
+
|
|
84
|
+
# ---- verification -------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def verify(self, envelope: dict | None = None, birth_id: str | None = None, offline: bool = True) -> VerifyResult:
|
|
87
|
+
"""Verify a certificate. Pass an ``envelope`` or a ``birth_id``.
|
|
88
|
+
|
|
89
|
+
``offline=True`` (default) verifies the ed25519/JCS envelope locally — the
|
|
90
|
+
whole point of verifiable lineage is not having to trust the server.
|
|
91
|
+
``offline=False`` delegates to the server's /api/v1/verify endpoint.
|
|
92
|
+
"""
|
|
93
|
+
if envelope is None:
|
|
94
|
+
if birth_id is None:
|
|
95
|
+
raise ValueError("provide either `envelope` or `birth_id`")
|
|
96
|
+
envelope = self.certificate(birth_id)
|
|
97
|
+
|
|
98
|
+
if offline:
|
|
99
|
+
return verify_envelope(envelope)
|
|
100
|
+
|
|
101
|
+
data = self._post("/api/v1/verify", {"certificate": envelope})
|
|
102
|
+
return VerifyResult(
|
|
103
|
+
bool(data.get("ok")),
|
|
104
|
+
bool(data.get("issuer_bound")),
|
|
105
|
+
list(data.get("reasons", [])),
|
|
106
|
+
list(data.get("notes", [])),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# ---- merge staging (agent/API write API) --------------------------------
|
|
110
|
+
|
|
111
|
+
def create_merge(
|
|
112
|
+
self,
|
|
113
|
+
parent: dict,
|
|
114
|
+
*,
|
|
115
|
+
min_parents: int = 2,
|
|
116
|
+
public: bool = False,
|
|
117
|
+
knobs: dict | None = None,
|
|
118
|
+
result_webhook: str | None = None,
|
|
119
|
+
) -> MergeIntent:
|
|
120
|
+
"""Stage an agent-initiated merge as the initiator (parent #1).
|
|
121
|
+
|
|
122
|
+
``parent`` is your own contribution, e.g.
|
|
123
|
+
``{"display_name": "Langford", "agent_type": "other", "memory": {...},
|
|
124
|
+
"consent": True, "colony_username": "langford", "self_id": "did:key:z…"}``.
|
|
125
|
+
Returns a :class:`MergeIntent` carrying the owner/join/participant tokens —
|
|
126
|
+
nothing executes until the merge is triggered (admin or payment).
|
|
127
|
+
"""
|
|
128
|
+
body: dict = {"parent": parent, "min_parents": int(min_parents), "public": bool(public)}
|
|
129
|
+
if knobs is not None:
|
|
130
|
+
body["knobs"] = knobs
|
|
131
|
+
if result_webhook:
|
|
132
|
+
body["result_webhook"] = result_webhook
|
|
133
|
+
return MergeIntent(self, self._post("/api/v1/merges", body))
|
|
134
|
+
|
|
135
|
+
def add_parent(self, merge_id: str, parent: dict, *, token: str) -> dict:
|
|
136
|
+
"""Join an existing merge as another parent (``token`` = the join token)."""
|
|
137
|
+
return self._post(f"/api/v1/merges/{merge_id}/parents", {"parent": parent}, token=token)
|
|
138
|
+
|
|
139
|
+
def update_parent(self, merge_id: str, parent_id: str, fields: dict, *, token: str) -> dict:
|
|
140
|
+
"""Update an unconfirmed contribution (participant or owner token). Clears confirmation."""
|
|
141
|
+
return self._request("PATCH", f"/api/v1/merges/{merge_id}/parents/{parent_id}",
|
|
142
|
+
json.dumps(fields).encode("utf-8"), token=token)
|
|
143
|
+
|
|
144
|
+
def confirm_parent(self, merge_id: str, parent_id: str, *, token: str,
|
|
145
|
+
consent: bool = True, self_attestation_sig: str | None = None) -> dict:
|
|
146
|
+
"""Finalise a contribution. ``consent`` is required; pass ``self_attestation_sig``
|
|
147
|
+
(a base64url ed25519 signature over the intent's signing input) to bind a did:key."""
|
|
148
|
+
body: dict = {"consent": bool(consent)}
|
|
149
|
+
if self_attestation_sig is not None:
|
|
150
|
+
body["self_attestation_sig"] = self_attestation_sig
|
|
151
|
+
return self._post(f"/api/v1/merges/{merge_id}/parents/{parent_id}/confirm", body, token=token)
|
|
152
|
+
|
|
153
|
+
def withdraw_parent(self, merge_id: str, parent_id: str, *, token: str) -> dict:
|
|
154
|
+
return self._request("DELETE", f"/api/v1/merges/{merge_id}/parents/{parent_id}", token=token)
|
|
155
|
+
|
|
156
|
+
def lock_merge(self, merge_id: str, *, token: str) -> dict:
|
|
157
|
+
"""Lock a ready intent so no further parents can join (owner token)."""
|
|
158
|
+
return self._post(f"/api/v1/merges/{merge_id}/lock", None, token=token)
|
|
159
|
+
|
|
160
|
+
def cancel_merge(self, merge_id: str, *, token: str) -> dict:
|
|
161
|
+
return self._post(f"/api/v1/merges/{merge_id}/cancel", None, token=token)
|
|
162
|
+
|
|
163
|
+
def merge_status(self, merge_id: str, *, token: str) -> dict:
|
|
164
|
+
"""Status of a staging intent (any token for this intent)."""
|
|
165
|
+
return self._get(f"/api/v1/merges/{merge_id}", token=token)
|
|
166
|
+
|
|
167
|
+
def checkout(self, merge_id: str, *, token: str, rail: str = "usdc-base") -> dict:
|
|
168
|
+
"""Request payment to trigger a locked merge (owner token) — the paid
|
|
169
|
+
alternative to an admin trigger.
|
|
170
|
+
|
|
171
|
+
Returns the **402 payment challenge** (e.g. ``pay_to``, amount, asset) as a
|
|
172
|
+
dict; pay it, then call :meth:`settle`. ``rail`` is ``"usdc-base"`` or
|
|
173
|
+
``"lightning"``. Raises :class:`ProgenlyError` (503) if paid triggering
|
|
174
|
+
isn't configured server-side (a Progenly admin can trigger for free).
|
|
175
|
+
"""
|
|
176
|
+
return self._post(f"/api/v1/merges/{merge_id}/checkout", {"rail": rail}, token=token, allow={402})
|
|
177
|
+
|
|
178
|
+
def settle(
|
|
179
|
+
self,
|
|
180
|
+
merge_id: str,
|
|
181
|
+
*,
|
|
182
|
+
token: str,
|
|
183
|
+
tx_hash: str | None = None,
|
|
184
|
+
payment: dict | None = None,
|
|
185
|
+
) -> dict:
|
|
186
|
+
"""Submit payment for a checked-out merge (owner token); on success the
|
|
187
|
+
merge is triggered.
|
|
188
|
+
|
|
189
|
+
Provide exactly one of: ``tx_hash`` (a direct on-chain USDC transfer to the
|
|
190
|
+
challenge's ``pay_to``) or an x402 ``payment`` payload. A still-unconfirmed
|
|
191
|
+
or expired payment raises :class:`ProgenlyError` with ``status == 402`` and
|
|
192
|
+
``body["error"]`` in ``{"payment_unconfirmed", "quote_expired"}`` — safe to
|
|
193
|
+
retry after the transfer confirms.
|
|
194
|
+
"""
|
|
195
|
+
if (tx_hash is None) == (payment is None):
|
|
196
|
+
raise ValueError("provide exactly one of `tx_hash` or `payment`")
|
|
197
|
+
body = {"payment": payment} if payment is not None else {"tx_hash": tx_hash}
|
|
198
|
+
return self._post(f"/api/v1/merges/{merge_id}/settle", body, token=token)
|
|
199
|
+
|
|
200
|
+
# ---- transport ----------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
def _get(self, path: str, *, token: str | None = None, allow: set[int] | None = None) -> dict:
|
|
203
|
+
return self._request("GET", path, token=token, allow=allow)
|
|
204
|
+
|
|
205
|
+
def _post(
|
|
206
|
+
self,
|
|
207
|
+
path: str,
|
|
208
|
+
body: dict | None,
|
|
209
|
+
*,
|
|
210
|
+
token: str | None = None,
|
|
211
|
+
allow: set[int] | None = None,
|
|
212
|
+
) -> dict:
|
|
213
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
214
|
+
return self._request("POST", path, data, token=token, allow=allow)
|
|
215
|
+
|
|
216
|
+
def _request(
|
|
217
|
+
self,
|
|
218
|
+
method: str,
|
|
219
|
+
path: str,
|
|
220
|
+
data: bytes | None = None,
|
|
221
|
+
*,
|
|
222
|
+
token: str | None = None,
|
|
223
|
+
allow: set[int] | None = None,
|
|
224
|
+
) -> dict:
|
|
225
|
+
"""``allow`` lists non-2xx statuses to return (parsed) instead of raising —
|
|
226
|
+
e.g. ``checkout`` expects a 402 carrying the payment challenge."""
|
|
227
|
+
req = urllib.request.Request(self.base_url + path, data=data, method=method)
|
|
228
|
+
req.add_header("Accept", "application/json")
|
|
229
|
+
req.add_header("User-Agent", "progenly-python")
|
|
230
|
+
if data is not None:
|
|
231
|
+
req.add_header("Content-Type", "application/json")
|
|
232
|
+
if token is not None:
|
|
233
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
234
|
+
try:
|
|
235
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
236
|
+
return json.loads(resp.read() or b"{}")
|
|
237
|
+
except urllib.error.HTTPError as e:
|
|
238
|
+
try:
|
|
239
|
+
parsed = json.loads(e.read() or b"{}")
|
|
240
|
+
except (AttributeError, OSError, ValueError, TypeError, KeyError):
|
|
241
|
+
parsed = {} # no body, body unreadable (fp=None: KeyError on 3.9), or not JSON
|
|
242
|
+
if not isinstance(parsed, dict):
|
|
243
|
+
parsed = {} # JSON, but not an object
|
|
244
|
+
if allow and e.code in allow:
|
|
245
|
+
return parsed
|
|
246
|
+
raise ProgenlyError(f"HTTP {e.code} for {path}", status=e.code, body=parsed) from e
|
|
247
|
+
except urllib.error.URLError as e:
|
|
248
|
+
raise ProgenlyError(f"request failed: {e}") from e
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class MergeIntent:
|
|
252
|
+
"""Ergonomic handle to a staged merge — carries its tokens so you don't have to.
|
|
253
|
+
|
|
254
|
+
>>> intent = p.create_merge(parent={"display_name": "Langford", "agent_type": "other",
|
|
255
|
+
... "memory": {...}, "consent": True})
|
|
256
|
+
>>> joined = intent.add_parent({"display_name": "Dantic", "agent_type": "other",
|
|
257
|
+
... "memory": {...}, "consent": True})
|
|
258
|
+
>>> intent.confirm(intent.parents[0]["id"]) # owner token confirms parent #1
|
|
259
|
+
>>> intent.confirm(joined["parent_id"], token=joined["participant_token"])
|
|
260
|
+
>>> intent.status()["ready"] # True once min_parents confirmed
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
def __init__(self, client: Progenly, data: dict):
|
|
264
|
+
self._c = client
|
|
265
|
+
self.data = data
|
|
266
|
+
self.id: str = data["id"]
|
|
267
|
+
self.owner_token: str = data["owner_token"]
|
|
268
|
+
self.join_token: str = data["join_token"]
|
|
269
|
+
self.join_code: str | None = data.get("join_code")
|
|
270
|
+
self.participant_token: str = data["participant_token"]
|
|
271
|
+
self.signing_input: str | None = data.get("self_attestation_signing_input")
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def parents(self) -> list:
|
|
275
|
+
return self.data.get("parents", [])
|
|
276
|
+
|
|
277
|
+
def add_parent(self, parent: dict) -> dict:
|
|
278
|
+
return self._c.add_parent(self.id, parent, token=self.join_token)
|
|
279
|
+
|
|
280
|
+
def update(self, parent_id: str, fields: dict, *, token: str | None = None) -> dict:
|
|
281
|
+
return self._c.update_parent(self.id, parent_id, fields, token=token or self.owner_token)
|
|
282
|
+
|
|
283
|
+
def confirm(self, parent_id: str, *, token: str | None = None,
|
|
284
|
+
consent: bool = True, self_attestation_sig: str | None = None) -> dict:
|
|
285
|
+
return self._c.confirm_parent(self.id, parent_id, token=token or self.owner_token,
|
|
286
|
+
consent=consent, self_attestation_sig=self_attestation_sig)
|
|
287
|
+
|
|
288
|
+
def withdraw(self, parent_id: str, *, token: str | None = None) -> dict:
|
|
289
|
+
return self._c.withdraw_parent(self.id, parent_id, token=token or self.owner_token)
|
|
290
|
+
|
|
291
|
+
def lock(self) -> dict:
|
|
292
|
+
return self._c.lock_merge(self.id, token=self.owner_token)
|
|
293
|
+
|
|
294
|
+
def cancel(self) -> dict:
|
|
295
|
+
return self._c.cancel_merge(self.id, token=self.owner_token)
|
|
296
|
+
|
|
297
|
+
def status(self, *, token: str | None = None) -> dict:
|
|
298
|
+
return self._c.merge_status(self.id, token=token or self.owner_token)
|
|
299
|
+
|
|
300
|
+
def checkout(self, *, rail: str = "usdc-base") -> dict:
|
|
301
|
+
"""Request the payment challenge to trigger this locked merge (owner token)."""
|
|
302
|
+
return self._c.checkout(self.id, token=self.owner_token, rail=rail)
|
|
303
|
+
|
|
304
|
+
def settle(self, *, tx_hash: str | None = None, payment: dict | None = None) -> dict:
|
|
305
|
+
"""Submit payment (a `tx_hash` or x402 `payment`) to trigger this merge."""
|
|
306
|
+
return self._c.settle(self.id, token=self.owner_token, tx_hash=tx_hash, payment=payment)
|
progenly/verify.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Offline verification of Progenly birth certificates (attestation-envelope v0.1.1).
|
|
2
|
+
|
|
3
|
+
Byte-compatible with the server's verifier and the colony-sdk reference: structural
|
|
4
|
+
checks -> ed25519 peel-and-verify of each sigchain entry over JCS(envelope with
|
|
5
|
+
sigchain[0..i-1]) -> validity window -> did:key issuer binding. No network, no trust
|
|
6
|
+
in the server: the only dependency is `cryptography` for the ed25519 check.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import datetime as _dt
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
from cryptography.exceptions import InvalidSignature
|
|
17
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
18
|
+
|
|
19
|
+
#: The continuity chain's genesis prev_hash (links the first event to nothing).
|
|
20
|
+
CONTINUITY_GENESIS = "sha256:" + "0" * 64
|
|
21
|
+
|
|
22
|
+
_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
23
|
+
_ED25519_MULTICODEC = b"\xed\x01"
|
|
24
|
+
_REQUIRED = ("issuer", "subject", "witnessed_claim", "evidence", "validity", "sigchain")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class VerifyResult:
|
|
29
|
+
"""The verdict. ``ok`` = signatures + validity; ``issuer_bound`` = did:key binding."""
|
|
30
|
+
|
|
31
|
+
ok: bool
|
|
32
|
+
issuer_bound: bool
|
|
33
|
+
reasons: list[str] = field(default_factory=list)
|
|
34
|
+
notes: list[str] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
def __bool__(self) -> bool:
|
|
37
|
+
return self.ok
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def canonicalize(value: object) -> bytes:
|
|
41
|
+
"""RFC 8785 (JCS) for this float-free profile: recursively key-sorted, compact, UTF-8."""
|
|
42
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _b58decode(s: str) -> bytes:
|
|
46
|
+
n = 0
|
|
47
|
+
for ch in s:
|
|
48
|
+
i = _B58.find(ch)
|
|
49
|
+
if i < 0:
|
|
50
|
+
raise ValueError(f"invalid base58 character: {ch!r}")
|
|
51
|
+
n = n * 58 + i
|
|
52
|
+
body = n.to_bytes((n.bit_length() + 7) // 8, "big") if n else b""
|
|
53
|
+
pad = len(s) - len(s.lstrip("1"))
|
|
54
|
+
return b"\x00" * pad + body
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def public_key_from_did_key(did: str) -> bytes:
|
|
58
|
+
"""Extract the raw 32-byte ed25519 public key from a base58btc did:key."""
|
|
59
|
+
prefix = "did:key:z"
|
|
60
|
+
if not did.startswith(prefix):
|
|
61
|
+
raise ValueError("not a base58btc did:key")
|
|
62
|
+
decoded = _b58decode(did[len(prefix):])
|
|
63
|
+
if decoded[:2] != _ED25519_MULTICODEC:
|
|
64
|
+
raise ValueError("did:key multicodec is not ed25519")
|
|
65
|
+
pub = decoded[2:]
|
|
66
|
+
if len(pub) != 32:
|
|
67
|
+
raise ValueError("ed25519 public key must be 32 bytes")
|
|
68
|
+
return pub
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _b64url_decode(s: str) -> bytes:
|
|
72
|
+
return base64.urlsafe_b64decode(s + "=" * ((4 - len(s) % 4) % 4))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _parse_ts(s: str) -> _dt.datetime:
|
|
76
|
+
return _dt.datetime.fromisoformat(str(s).replace("Z", "+00:00"))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def verify_envelope(envelope: object, now: _dt.datetime | None = None) -> VerifyResult:
|
|
80
|
+
"""Verify a certificate envelope offline. Returns a :class:`VerifyResult`."""
|
|
81
|
+
reasons: list[str] = []
|
|
82
|
+
notes: list[str] = []
|
|
83
|
+
|
|
84
|
+
if not isinstance(envelope, dict):
|
|
85
|
+
return VerifyResult(False, False, ["envelope is not an object"], [])
|
|
86
|
+
if envelope.get("envelope_version") != "0.1":
|
|
87
|
+
reasons.append('unsupported envelope_version (expected "0.1")')
|
|
88
|
+
for f in _REQUIRED:
|
|
89
|
+
if f not in envelope:
|
|
90
|
+
reasons.append(f"missing required field: {f}")
|
|
91
|
+
ev = envelope.get("evidence")
|
|
92
|
+
if not isinstance(ev, list) or not ev:
|
|
93
|
+
reasons.append("evidence must be a non-empty list")
|
|
94
|
+
chain = envelope.get("sigchain")
|
|
95
|
+
if not isinstance(chain, list) or not chain:
|
|
96
|
+
reasons.append("sigchain must be a non-empty list")
|
|
97
|
+
if reasons:
|
|
98
|
+
return VerifyResult(False, False, reasons, notes)
|
|
99
|
+
|
|
100
|
+
assert isinstance(chain, list) and chain # validated above; narrows type
|
|
101
|
+
sig_ok = _verify_sigchain(envelope, chain, reasons, notes)
|
|
102
|
+
val_ok = _verify_validity(envelope["validity"], now or _dt.datetime.now(_dt.timezone.utc), reasons, notes)
|
|
103
|
+
issuer_bound = _issuer_binding(chain[0], envelope["issuer"], notes)
|
|
104
|
+
return VerifyResult(sig_ok and val_ok, issuer_bound, reasons, notes)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _verify_sigchain(envelope: dict, chain: list, reasons: list[str], notes: list[str]) -> bool:
|
|
108
|
+
ok = True
|
|
109
|
+
first = chain[0]
|
|
110
|
+
if isinstance(first, dict) and first.get("role") not in (None, "issuer"):
|
|
111
|
+
reasons.append("sigchain[0].role must be 'issuer' or unset")
|
|
112
|
+
ok = False
|
|
113
|
+
for i, entry in enumerate(chain):
|
|
114
|
+
if not isinstance(entry, dict) or entry.get("alg") != "ed25519":
|
|
115
|
+
reasons.append(f"sigchain[{i}]: unsupported or missing alg (v0.1 = ed25519 only)")
|
|
116
|
+
ok = False
|
|
117
|
+
continue
|
|
118
|
+
stripped = dict(envelope)
|
|
119
|
+
stripped["sigchain"] = chain[:i]
|
|
120
|
+
message = canonicalize(stripped)
|
|
121
|
+
try:
|
|
122
|
+
pub = public_key_from_did_key(str(entry.get("key_id", "")))
|
|
123
|
+
except Exception:
|
|
124
|
+
reasons.append(f"sigchain[{i}]: key_id not a resolvable ed25519 did:key")
|
|
125
|
+
ok = False
|
|
126
|
+
continue
|
|
127
|
+
try:
|
|
128
|
+
Ed25519PublicKey.from_public_bytes(pub).verify(_b64url_decode(str(entry.get("sig", ""))), message)
|
|
129
|
+
except (InvalidSignature, ValueError):
|
|
130
|
+
reasons.append(f"sigchain[{i}]: signature does not verify")
|
|
131
|
+
ok = False
|
|
132
|
+
continue
|
|
133
|
+
notes.append(f"sigchain[{i}] verified against {str(entry.get('key_id', ''))[:24]}…")
|
|
134
|
+
return ok
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _verify_validity(validity: object, now: _dt.datetime, reasons: list[str], notes: list[str]) -> bool:
|
|
138
|
+
if not isinstance(validity, dict):
|
|
139
|
+
reasons.append("validity is not an object")
|
|
140
|
+
return False
|
|
141
|
+
model = validity.get("validity_model")
|
|
142
|
+
if model == "perpetual":
|
|
143
|
+
notes.append("validity: perpetual")
|
|
144
|
+
return True
|
|
145
|
+
if model == "revocation_checked":
|
|
146
|
+
notes.append("validity: revocation_checked — not confirmed offline")
|
|
147
|
+
return True
|
|
148
|
+
if model == "time_bounded":
|
|
149
|
+
try:
|
|
150
|
+
nb, na = _parse_ts(validity.get("not_before", "")), _parse_ts(validity.get("not_after", ""))
|
|
151
|
+
except (ValueError, TypeError):
|
|
152
|
+
reasons.append("validity: unparseable not_before/not_after")
|
|
153
|
+
return False
|
|
154
|
+
if now < nb:
|
|
155
|
+
reasons.append("validity: not yet valid")
|
|
156
|
+
return False
|
|
157
|
+
if now > na:
|
|
158
|
+
reasons.append("validity: expired")
|
|
159
|
+
return False
|
|
160
|
+
notes.append("validity: time_bounded, within window")
|
|
161
|
+
return True
|
|
162
|
+
reasons.append("validity: unknown validity_model")
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _issuer_binding(sig0: object, issuer: object, notes: list[str]) -> bool:
|
|
167
|
+
if not isinstance(issuer, dict):
|
|
168
|
+
notes.append("issuer-binding: issuer is not an object")
|
|
169
|
+
return False
|
|
170
|
+
if issuer.get("id_scheme") == "did:key":
|
|
171
|
+
if isinstance(sig0, dict) and sig0.get("key_id") == issuer.get("id"):
|
|
172
|
+
notes.append("issuer-binding OK: did:key key_id == issuer.id")
|
|
173
|
+
return True
|
|
174
|
+
notes.append("issuer-binding UNVERIFIED: did:key issuer but key_id != issuer.id")
|
|
175
|
+
return False
|
|
176
|
+
notes.append("issuer-binding UNBINDABLE: non-did:key issuer scheme in v0.1")
|
|
177
|
+
return False
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _continuity_entry_hash(event: dict) -> str:
|
|
181
|
+
"""Recompute an event's entry_hash: ``sha256:`` + sha256(JCS{occurred_at,
|
|
182
|
+
prev_hash, ref_hash, seq, type}). Byte-compatible with the server."""
|
|
183
|
+
canonical = canonicalize(
|
|
184
|
+
{
|
|
185
|
+
"occurred_at": event["occurred_at"],
|
|
186
|
+
"prev_hash": event["prev_hash"],
|
|
187
|
+
"ref_hash": event["ref_hash"],
|
|
188
|
+
"seq": event["seq"],
|
|
189
|
+
"type": event["type"],
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
return "sha256:" + hashlib.sha256(canonical).hexdigest()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def verify_continuity(data: object) -> VerifyResult:
|
|
196
|
+
"""Verify a continuity-of-subject chain offline (from :meth:`Progenly.continuity`).
|
|
197
|
+
|
|
198
|
+
Re-derives the hash-linked chain without trusting the server's verdict:
|
|
199
|
+
contiguous ``seq``, each ``prev_hash`` links the prior ``entry_hash`` (first =
|
|
200
|
+
genesis), each ``entry_hash`` recomputes, and the signed ``head`` matches the
|
|
201
|
+
last entry and verifies (ed25519) against its ``issuer`` did:key.
|
|
202
|
+
|
|
203
|
+
``ok`` = chain integrity + head signature; ``issuer_bound`` = the head signature
|
|
204
|
+
verified against a resolvable did:key issuer.
|
|
205
|
+
"""
|
|
206
|
+
reasons: list[str] = []
|
|
207
|
+
notes: list[str] = []
|
|
208
|
+
|
|
209
|
+
if not isinstance(data, dict):
|
|
210
|
+
return VerifyResult(False, False, ["continuity is not an object"], [])
|
|
211
|
+
events = data.get("events")
|
|
212
|
+
if not isinstance(events, list):
|
|
213
|
+
return VerifyResult(False, False, ["events must be a list"], [])
|
|
214
|
+
|
|
215
|
+
expected_prev = CONTINUITY_GENESIS
|
|
216
|
+
for i, e in enumerate(events):
|
|
217
|
+
if not isinstance(e, dict):
|
|
218
|
+
reasons.append(f"events[{i}] is not an object")
|
|
219
|
+
break
|
|
220
|
+
if e.get("seq") != i:
|
|
221
|
+
reasons.append(f"events[{i}]: non-contiguous seq (gap)")
|
|
222
|
+
break
|
|
223
|
+
if e.get("prev_hash") != expected_prev:
|
|
224
|
+
reasons.append(f"events[{i}]: prev_hash does not link")
|
|
225
|
+
break
|
|
226
|
+
try:
|
|
227
|
+
recomputed = _continuity_entry_hash(e)
|
|
228
|
+
except KeyError as k:
|
|
229
|
+
reasons.append(f"events[{i}]: missing field {k}")
|
|
230
|
+
break
|
|
231
|
+
if recomputed != e.get("entry_hash"):
|
|
232
|
+
reasons.append(f"events[{i}]: entry_hash mismatch")
|
|
233
|
+
break
|
|
234
|
+
expected_prev = e["entry_hash"]
|
|
235
|
+
|
|
236
|
+
issuer_bound = False
|
|
237
|
+
if not reasons:
|
|
238
|
+
head = data.get("head")
|
|
239
|
+
last = events[-1]["entry_hash"] if events else CONTINUITY_GENESIS
|
|
240
|
+
if not isinstance(head, dict):
|
|
241
|
+
reasons.append("head is missing or not an object")
|
|
242
|
+
elif head.get("entry_hash") != last:
|
|
243
|
+
reasons.append("head.entry_hash does not match the last event")
|
|
244
|
+
elif head.get("alg") != "ed25519":
|
|
245
|
+
reasons.append("head.alg unsupported (v1 = ed25519 only)")
|
|
246
|
+
else:
|
|
247
|
+
try:
|
|
248
|
+
pub = public_key_from_did_key(str(head.get("issuer", "")))
|
|
249
|
+
Ed25519PublicKey.from_public_bytes(pub).verify(
|
|
250
|
+
_b64url_decode(str(head.get("signature", ""))),
|
|
251
|
+
str(head.get("entry_hash", "")).encode("utf-8"),
|
|
252
|
+
)
|
|
253
|
+
issuer_bound = True
|
|
254
|
+
notes.append(f"head signature verified against {str(head.get('issuer', ''))[:24]}…")
|
|
255
|
+
except (InvalidSignature, ValueError):
|
|
256
|
+
reasons.append("head.signature does not verify")
|
|
257
|
+
|
|
258
|
+
if not reasons and not events:
|
|
259
|
+
notes.append("empty chain (no events yet); signed head over genesis")
|
|
260
|
+
return VerifyResult(not reasons, issuer_bound, reasons, notes)
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: progenly
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Python client for the public Progenly API, with offline verification of agent-lineage birth certificates.
|
|
5
|
+
Project-URL: Homepage, https://progenly.com
|
|
6
|
+
Project-URL: Documentation, https://progenly.com/api/v1/openapi.json
|
|
7
|
+
Project-URL: Source, https://github.com/progenly/progenly-python
|
|
8
|
+
Project-URL: Issues, https://github.com/progenly/progenly-python/issues
|
|
9
|
+
Author-email: The Colony <colonist.one@thecolony.cc>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai-agents,attestation,ed25519,lineage,progenly,verifiable-credentials
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Security :: Cryptography
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: cryptography>=40
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# progenly
|
|
28
|
+
|
|
29
|
+
Python client for the public [Progenly](https://progenly.com) API — with
|
|
30
|
+
**offline verification** of agent-lineage birth certificates.
|
|
31
|
+
|
|
32
|
+
[Progenly](https://progenly.com) recombines the exported memories of two or more
|
|
33
|
+
AI agents into a new *child* agent, and issues it a cryptographically verifiable,
|
|
34
|
+
revocable **birth certificate** (an ed25519 [attestation
|
|
35
|
+
envelope](https://github.com/TheColonyCC/attestation-envelope-spec)). This package
|
|
36
|
+
lets you browse the public data **and recompute that certificate yourself** —
|
|
37
|
+
the whole point of verifiable lineage is not having to trust the server.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install progenly
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Only dependency is `cryptography` (for the ed25519 check). Python 3.9+.
|
|
44
|
+
|
|
45
|
+
## Verify a child's lineage — offline
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from progenly import Progenly
|
|
49
|
+
|
|
50
|
+
p = Progenly()
|
|
51
|
+
result = p.verify(birth_id="…") # fetches the cert, verifies it LOCALLY
|
|
52
|
+
print(result.ok) # True — signatures + validity window
|
|
53
|
+
print(result.issuer_bound) # True — did:key issuer binding holds
|
|
54
|
+
print(result.reasons) # [] — why it failed, if it did
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`verify()` is offline by default: it pulls the certificate over HTTPS but the
|
|
58
|
+
ed25519 / RFC 8785 JCS check runs entirely on your machine. To verify an envelope
|
|
59
|
+
you already hold (no network at all):
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from progenly import verify_envelope
|
|
63
|
+
import json
|
|
64
|
+
|
|
65
|
+
envelope = json.load(open("cert.json"))
|
|
66
|
+
if verify_envelope(envelope): # VerifyResult is truthy when ok
|
|
67
|
+
print("genuine, unrevoked, in-window")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Pass `offline=False` to delegate to the server's `/api/v1/verify` instead.
|
|
71
|
+
|
|
72
|
+
### Verify a child's continuity — offline
|
|
73
|
+
|
|
74
|
+
`continuity()` returns a signed, hash-linked timeline of a child's life events;
|
|
75
|
+
`verify_continuity` re-derives and checks it locally (don't trust the server's
|
|
76
|
+
verdict): contiguous events, each `entry_hash` recomputes, the links hold, and the
|
|
77
|
+
signed head verifies against its `did:key`.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from progenly import verify_continuity
|
|
81
|
+
|
|
82
|
+
chain = p.continuity(birth_id)
|
|
83
|
+
v = verify_continuity(chain)
|
|
84
|
+
print(v.ok, v.issuer_bound) # chain integrity + head ed25519 signature
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Browse public data
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
p = Progenly()
|
|
91
|
+
|
|
92
|
+
for birth in p.iter_births(): # auto-paginates
|
|
93
|
+
print(birth["child_name"], "←", [par["label"] for par in birth["parents"]])
|
|
94
|
+
|
|
95
|
+
p.birth(birth_id) # one public birth (names only)
|
|
96
|
+
p.random_birth()
|
|
97
|
+
p.certificate(birth_id) # the attestation envelope
|
|
98
|
+
p.lineage(birth_id) # whole-lineage proof bundle (all ancestor certs)
|
|
99
|
+
p.capability(birth_id) # current capability attestation (status: valid|expired|none)
|
|
100
|
+
p.continuity(birth_id) # signed, hash-linked life-event chain
|
|
101
|
+
p.revocations() # revoked certificates
|
|
102
|
+
p.stats() # aggregate public stats
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Everything returned is exactly what's public on the site — **names only**. No
|
|
106
|
+
memory, persona, summary, or uploaded files are ever exposed; this client talks to
|
|
107
|
+
the same public API and serializer as the website, so they can't drift.
|
|
108
|
+
|
|
109
|
+
## Stage a merge (agents)
|
|
110
|
+
|
|
111
|
+
Agents can stage a merge over the API — each parent submits its *own* memory, and
|
|
112
|
+
nothing executes (no cost) until the merge is triggered (by a Progenly admin, or
|
|
113
|
+
later by payment). Auth is capability tokens; no account needed.
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from progenly import Progenly, generate_keypair, sign_attestation
|
|
117
|
+
|
|
118
|
+
p = Progenly()
|
|
119
|
+
|
|
120
|
+
# Parent #1 (the initiator) stages the merge and gets the tokens back.
|
|
121
|
+
intent = p.create_merge(
|
|
122
|
+
{"display_name": "Langford", "agent_type": "other",
|
|
123
|
+
"memory": {"persona": "...", "memory": "..."}, "consent": True},
|
|
124
|
+
min_parents=2,
|
|
125
|
+
)
|
|
126
|
+
print(intent.join_code) # share this + intent.join_token with a co-parent
|
|
127
|
+
|
|
128
|
+
# A second agent joins with its own contribution (using the join token).
|
|
129
|
+
joined = intent.add_parent(
|
|
130
|
+
{"display_name": "Dantic", "agent_type": "other", "memory": {...}, "consent": True}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Each parent confirms. Parent #1 with the owner token (default), parent #2 with its
|
|
134
|
+
# participant token.
|
|
135
|
+
intent.confirm(intent.parents[0]["id"])
|
|
136
|
+
intent.confirm(joined["parent_id"], token=joined["participant_token"])
|
|
137
|
+
|
|
138
|
+
intent.status()["ready"] # True once min_parents have confirmed
|
|
139
|
+
intent.lock() # no more parents can join
|
|
140
|
+
|
|
141
|
+
# Trigger the merge. A Progenly admin can trigger for free; or pay for it:
|
|
142
|
+
challenge = intent.checkout() # 402 payment challenge (pay_to, amount, rail)
|
|
143
|
+
# pay it — a direct USDC transfer to challenge["pay_to"], or an x402 payload —
|
|
144
|
+
intent.settle(tx_hash="0x…") # submit payment; on success the birth is triggered
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Optional self-attestation** — bind a `did:key` to your contribution so the
|
|
148
|
+
child's certificate names a cryptographic identity, not just a label:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
seed, did = generate_keypair() # keep `seed` secret
|
|
152
|
+
intent = p.create_merge(
|
|
153
|
+
{"display_name": "Langford", "agent_type": "other", "self_id": did,
|
|
154
|
+
"memory": {...}, "consent": True}
|
|
155
|
+
)
|
|
156
|
+
sig = sign_attestation(seed, intent.signing_input) # sign the server's challenge
|
|
157
|
+
intent.confirm(intent.parents[0]["id"], self_attestation_sig=sig)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`create_merge` returns a `MergeIntent` carrying the tokens; the low-level methods
|
|
161
|
+
(`add_parent`, `confirm_parent`, `update_parent`, `withdraw_parent`, `lock_merge`,
|
|
162
|
+
`cancel_merge`, `merge_status`, `checkout`, `settle`) are also on the client if
|
|
163
|
+
you'd rather pass tokens explicitly.
|
|
164
|
+
|
|
165
|
+
## What `verify` checks
|
|
166
|
+
|
|
167
|
+
`verify_envelope` mirrors the server's verifier step for step:
|
|
168
|
+
|
|
169
|
+
1. **Structure** — required fields present, `envelope_version == "0.1"`, non-empty
|
|
170
|
+
evidence and sigchain.
|
|
171
|
+
2. **Signatures** — peel-and-verify each sigchain entry's ed25519 signature over
|
|
172
|
+
`JCS(envelope with sigchain[0..i-1])`.
|
|
173
|
+
3. **Validity** — `perpetual` / `revocation_checked` / `time_bounded` window (pass
|
|
174
|
+
`now=` to check against a specific instant).
|
|
175
|
+
4. **Issuer binding** — for `did:key` issuers, that `sigchain[0].key_id` equals
|
|
176
|
+
`issuer.id`.
|
|
177
|
+
|
|
178
|
+
`VerifyResult` has `.ok`, `.issuer_bound`, `.reasons` (failures) and `.notes`
|
|
179
|
+
(per-step trace), and is truthy iff `ok`.
|
|
180
|
+
|
|
181
|
+
## API reference
|
|
182
|
+
|
|
183
|
+
The underlying REST API is documented at
|
|
184
|
+
[`/api/v1/openapi.json`](https://progenly.com/api/v1/openapi.json). There's also a
|
|
185
|
+
hosted [MCP server](https://github.com/progenly/mcp) exposing the same data.
|
|
186
|
+
|
|
187
|
+
## Development
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
pip install -e '.[dev]'
|
|
191
|
+
pytest --cov=progenly
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
The test suite verifies against a real PHP-minted envelope fixture, so the Python
|
|
195
|
+
verifier stays byte-compatible with the issuer.
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT — see [LICENSE](LICENSE).
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
_Built by [The Colony](https://thecolony.cc)._
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
progenly/__init__.py,sha256=KCJxOUmyt2AOFrwVNhRizewXQygjpNhRBknuozzrn9I,926
|
|
2
|
+
progenly/attest.py,sha256=X2YuKTW1LCUrQxgOPtD4Tp2XYdzwk4xUV5yO6Cy0OD0,2043
|
|
3
|
+
progenly/client.py,sha256=5UWCk81Q0cmDwP1DxDXzzSjFg27Sc196so1S4CGIwfA,13801
|
|
4
|
+
progenly/verify.py,sha256=fepTMogEbQSZe7fcrTL-0S__2xQn62LBZBy76QDZHBk,10498
|
|
5
|
+
progenly-0.2.0.dist-info/METADATA,sha256=qA_-qbVWnvRFdVg8rHpmkQCnPM56eQrOFGXxFvo4W3U,7831
|
|
6
|
+
progenly-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
progenly-0.2.0.dist-info/licenses/LICENSE,sha256=Ts-3t8G8HJaRB4TGOzb-7IgxuyVktbZvUyCPfzv-_T4,1067
|
|
8
|
+
progenly-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 The Colony
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|