orangecheck 0.1.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.
@@ -0,0 +1,60 @@
1
+ """
2
+ orangecheck — Python SDK for OrangeCheck.
3
+
4
+ Proof of Bitcoin stake for the open web. A sybil-resistance primitive for
5
+ Django, Flask, FastAPI, or anything on the Python side of the web.
6
+
7
+ Three load-bearing functions, mirroring the JS SDK:
8
+
9
+ from orangecheck import check, verify, discover
10
+
11
+ result = check(addr="bc1q...", min_sats=100_000, min_days=30)
12
+ if result.ok:
13
+ ... # let them through
14
+
15
+ All functions are also available on the Client class for configuring a
16
+ custom API base URL, timeouts, or a shared httpx session:
17
+
18
+ from orangecheck import Client
19
+ oc = Client(base_url="https://my-orangecheck.example.com")
20
+ await oc.check_async(addr="bc1q...", min_sats=100_000)
21
+ """
22
+
23
+ from .client import AsyncClient, Client
24
+ from .errors import OrangeCheckError, RateLimitError, VerificationError
25
+ from .top_level import challenge_issue, challenge_verify, check, discover, verify
26
+ from .types import (
27
+ AttestationSummary,
28
+ Challenge,
29
+ ChallengeVerified,
30
+ CheckResult,
31
+ DiscoverResult,
32
+ IdentityBinding,
33
+ VerifyOutcome,
34
+ )
35
+
36
+ __all__ = [
37
+ # Top-level functions (sync)
38
+ "check",
39
+ "verify",
40
+ "discover",
41
+ "challenge_issue",
42
+ "challenge_verify",
43
+ # Clients
44
+ "Client",
45
+ "AsyncClient",
46
+ # Types
47
+ "CheckResult",
48
+ "VerifyOutcome",
49
+ "DiscoverResult",
50
+ "Challenge",
51
+ "ChallengeVerified",
52
+ "IdentityBinding",
53
+ "AttestationSummary",
54
+ # Errors
55
+ "OrangeCheckError",
56
+ "RateLimitError",
57
+ "VerificationError",
58
+ ]
59
+
60
+ __version__ = "0.1.0"
@@ -0,0 +1,68 @@
1
+ """``python -m orangecheck`` — a tiny CLI, mostly for quick sanity checks.
2
+
3
+ Prefer the TypeScript ``oc`` CLI (``npm install -g @orangecheck/cli``) for
4
+ day-to-day use — it has more features. This one is here so Python users
5
+ can smoke-test the package after ``pip install``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+
14
+ from . import __version__, check
15
+
16
+
17
+ def main() -> int:
18
+ parser = argparse.ArgumentParser(prog="orangecheck", description="OrangeCheck Python SDK")
19
+ parser.add_argument("--version", action="version", version=f"orangecheck {__version__}")
20
+
21
+ sub = parser.add_subparsers(dest="cmd")
22
+
23
+ c = sub.add_parser("check", help="Gate on an OrangeCheck proof")
24
+ c.add_argument("--addr")
25
+ c.add_argument("--id")
26
+ c.add_argument("--identity")
27
+ c.add_argument("--min-sats", type=int, default=0)
28
+ c.add_argument("--min-days", type=int, default=0)
29
+ c.add_argument("--base-url", default=None)
30
+
31
+ args = parser.parse_args()
32
+
33
+ if args.cmd == "check":
34
+ result = check(
35
+ addr=args.addr,
36
+ id=args.id,
37
+ identity=args.identity,
38
+ min_sats=args.min_sats,
39
+ min_days=args.min_days,
40
+ base_url=args.base_url,
41
+ )
42
+ json.dump(
43
+ {
44
+ "ok": result.ok,
45
+ "sats": result.sats,
46
+ "days": result.days,
47
+ "score": result.score,
48
+ "attestation_id": result.attestation_id,
49
+ "address": result.address,
50
+ "identities": [
51
+ {"protocol": i.protocol, "identifier": i.identifier}
52
+ for i in result.identities
53
+ ],
54
+ "network": result.network,
55
+ "reasons": list(result.reasons),
56
+ },
57
+ sys.stdout,
58
+ indent=2,
59
+ )
60
+ sys.stdout.write("\n")
61
+ return 0 if result.ok else 2
62
+
63
+ parser.print_help()
64
+ return 1
65
+
66
+
67
+ if __name__ == "__main__":
68
+ raise SystemExit(main())
orangecheck/client.py ADDED
@@ -0,0 +1,413 @@
1
+ """
2
+ Client class for the OrangeCheck Python SDK.
3
+
4
+ Wraps the hosted ochk.io API. Provides both sync and async variants so
5
+ callers can pick whichever their framework expects.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ import httpx
13
+
14
+ from .errors import OrangeCheckError, RateLimitError, VerificationError
15
+ from .types import (
16
+ AttestationSummary,
17
+ Challenge,
18
+ ChallengeVerified,
19
+ CheckResult,
20
+ DiscoverResult,
21
+ IdentityBinding,
22
+ VerifyOutcome,
23
+ )
24
+
25
+ DEFAULT_BASE_URL = "https://ochk.io"
26
+ DEFAULT_TIMEOUT = 10.0
27
+ DEFAULT_UA = "orangecheck-py/0.1.0"
28
+
29
+
30
+ def _raise_for_status(status: int, body: Any) -> None:
31
+ if status == 429:
32
+ raise RateLimitError("rate limited", status=status, body=body)
33
+ if status == 404:
34
+ raise OrangeCheckError("not found", status=status, body=body)
35
+ if status >= 400:
36
+ err = "server error"
37
+ if isinstance(body, dict):
38
+ err = body.get("error") or body.get("reason") or err
39
+ raise OrangeCheckError(err, status=status, body=body)
40
+
41
+
42
+ class _Common:
43
+ """Shared helpers for the sync and async clients."""
44
+
45
+ def __init__(self, base_url: str = DEFAULT_BASE_URL, *, timeout: float = DEFAULT_TIMEOUT):
46
+ self.base_url = base_url.rstrip("/")
47
+ self.timeout = timeout
48
+
49
+ @staticmethod
50
+ def _check_params(
51
+ *,
52
+ addr: str | None,
53
+ id: str | None,
54
+ identity: str | IdentityBinding | None,
55
+ min_sats: int | None,
56
+ min_days: int | None,
57
+ ) -> dict[str, str]:
58
+ if not (addr or id or identity):
59
+ raise ValueError("must provide addr, id, or identity")
60
+
61
+ q: dict[str, str] = {}
62
+ if addr:
63
+ q["addr"] = addr
64
+ elif id:
65
+ q["id"] = id
66
+ elif identity:
67
+ if isinstance(identity, IdentityBinding):
68
+ q["identity"] = f"{identity.protocol}:{identity.identifier}"
69
+ else:
70
+ q["identity"] = identity
71
+ if min_sats is not None:
72
+ q["min_sats"] = str(int(min_sats))
73
+ if min_days is not None:
74
+ q["min_days"] = str(int(min_days))
75
+ return q
76
+
77
+ @staticmethod
78
+ def _discover_params(
79
+ *,
80
+ addr: str | None,
81
+ id: str | None,
82
+ identity: str | IdentityBinding | None,
83
+ limit: int | None,
84
+ ) -> dict[str, str]:
85
+ if not (addr or id or identity):
86
+ raise ValueError("must provide addr, id, or identity")
87
+ q: dict[str, str] = {}
88
+ if addr:
89
+ q["addr"] = addr
90
+ elif id:
91
+ q["id"] = id
92
+ elif identity:
93
+ if isinstance(identity, IdentityBinding):
94
+ q["identity"] = f"{identity.protocol}:{identity.identifier}"
95
+ else:
96
+ q["identity"] = identity
97
+ if limit is not None:
98
+ q["limit"] = str(int(limit))
99
+ return q
100
+
101
+ @staticmethod
102
+ def _challenge_params(
103
+ *,
104
+ addr: str,
105
+ audience: str | None,
106
+ purpose: str | None,
107
+ ttl: int | None,
108
+ ) -> dict[str, str]:
109
+ if not addr:
110
+ raise ValueError("addr is required")
111
+ q: dict[str, str] = {"addr": addr}
112
+ if audience:
113
+ q["audience"] = audience
114
+ if purpose:
115
+ q["purpose"] = purpose
116
+ if ttl is not None:
117
+ q["ttl"] = str(int(ttl))
118
+ return q
119
+
120
+ @staticmethod
121
+ def _headers() -> dict[str, str]:
122
+ return {"User-Agent": DEFAULT_UA, "Accept": "application/json"}
123
+
124
+
125
+ class Client(_Common):
126
+ """Synchronous OrangeCheck client. One per thread."""
127
+
128
+ def __init__(
129
+ self,
130
+ base_url: str = DEFAULT_BASE_URL,
131
+ *,
132
+ timeout: float = DEFAULT_TIMEOUT,
133
+ session: httpx.Client | None = None,
134
+ ):
135
+ super().__init__(base_url, timeout=timeout)
136
+ self._session = session or httpx.Client(timeout=timeout, headers=self._headers())
137
+
138
+ def close(self) -> None:
139
+ self._session.close()
140
+
141
+ def __enter__(self) -> "Client":
142
+ return self
143
+
144
+ def __exit__(self, *_: Any) -> None:
145
+ self.close()
146
+
147
+ def _get(self, path: str, params: dict[str, str] | None = None) -> Any:
148
+ r = self._session.get(f"{self.base_url}{path}", params=params)
149
+ body: Any
150
+ try:
151
+ body = r.json()
152
+ except ValueError:
153
+ body = r.text
154
+ if r.status_code != 200:
155
+ _raise_for_status(r.status_code, body)
156
+ return body
157
+
158
+ def _post(self, path: str, body: dict[str, Any]) -> Any:
159
+ r = self._session.post(
160
+ f"{self.base_url}{path}",
161
+ json=body,
162
+ headers={"Content-Type": "application/json"},
163
+ )
164
+ try:
165
+ out = r.json()
166
+ except ValueError:
167
+ out = r.text
168
+ if r.status_code not in (200, 401):
169
+ _raise_for_status(r.status_code, out)
170
+ return r.status_code, out
171
+
172
+ # --- Public methods ----------------------------------------------------
173
+
174
+ def check(
175
+ self,
176
+ *,
177
+ addr: str | None = None,
178
+ id: str | None = None,
179
+ identity: str | IdentityBinding | None = None,
180
+ min_sats: int | None = None,
181
+ min_days: int | None = None,
182
+ ) -> CheckResult:
183
+ """Look up the most recent attestation for a subject and gate it against thresholds."""
184
+ try:
185
+ body = self._get("/api/check", self._check_params(
186
+ addr=addr, id=id, identity=identity, min_sats=min_sats, min_days=min_days
187
+ ))
188
+ except OrangeCheckError as e:
189
+ # /api/check returns 404 when no attestation exists; surface as a
190
+ # "not found" CheckResult rather than an exception so callers can
191
+ # gate on result.ok without catching.
192
+ if e.status == 404 and isinstance(e.body, dict):
193
+ return CheckResult.from_json({"ok": False, **e.body})
194
+ raise
195
+ return CheckResult.from_json(body)
196
+
197
+ def verify(
198
+ self,
199
+ *,
200
+ addr: str,
201
+ msg: str,
202
+ sig: str,
203
+ scheme: str = "bip322",
204
+ ) -> VerifyOutcome:
205
+ """Verify a raw ``(addr, msg, sig)`` attestation against live chain state."""
206
+ _, body = self._post(
207
+ "/api/verify",
208
+ {"addr": addr, "msg": msg, "sig": sig, "scheme": scheme},
209
+ )
210
+ return VerifyOutcome.from_json(body)
211
+
212
+ def discover(
213
+ self,
214
+ *,
215
+ addr: str | None = None,
216
+ id: str | None = None,
217
+ identity: str | IdentityBinding | None = None,
218
+ limit: int | None = None,
219
+ ) -> DiscoverResult:
220
+ """List attestations known for a subject."""
221
+ body = self._get("/api/discover", self._discover_params(
222
+ addr=addr, id=id, identity=identity, limit=limit
223
+ ))
224
+ return DiscoverResult.from_json(body)
225
+
226
+ def challenge_issue(
227
+ self,
228
+ *,
229
+ addr: str,
230
+ audience: str | None = None,
231
+ purpose: str | None = None,
232
+ ttl: int | None = None,
233
+ ) -> Challenge:
234
+ """Mint a short-lived signed-challenge message."""
235
+ body = self._get(
236
+ "/api/challenge",
237
+ self._challenge_params(addr=addr, audience=audience, purpose=purpose, ttl=ttl),
238
+ )
239
+ return Challenge.from_json(body)
240
+
241
+ def challenge_verify(
242
+ self,
243
+ *,
244
+ message: str,
245
+ signature: str,
246
+ scheme: str = "bip322",
247
+ expected_nonce: str | None = None,
248
+ expected_audience: str | None = None,
249
+ expected_purpose: str | None = None,
250
+ ) -> ChallengeVerified:
251
+ """Verify a signed challenge. Raises :class:`VerificationError` on failure."""
252
+ body: dict[str, Any] = {
253
+ "message": message,
254
+ "signature": signature,
255
+ "scheme": scheme,
256
+ }
257
+ if expected_nonce:
258
+ body["expectedNonce"] = expected_nonce
259
+ if expected_audience:
260
+ body["expectedAudience"] = expected_audience
261
+ if expected_purpose:
262
+ body["expectedPurpose"] = expected_purpose
263
+
264
+ status, resp = self._post("/api/challenge", body)
265
+ if status == 200 and isinstance(resp, dict) and resp.get("ok"):
266
+ return ChallengeVerified.from_json(resp)
267
+ reason = resp.get("reason") if isinstance(resp, dict) else "unknown"
268
+ raise VerificationError(
269
+ f"challenge verification failed: {reason}", status=status, body=resp
270
+ )
271
+
272
+
273
+ class AsyncClient(_Common):
274
+ """Async variant. One per event loop."""
275
+
276
+ def __init__(
277
+ self,
278
+ base_url: str = DEFAULT_BASE_URL,
279
+ *,
280
+ timeout: float = DEFAULT_TIMEOUT,
281
+ session: httpx.AsyncClient | None = None,
282
+ ):
283
+ super().__init__(base_url, timeout=timeout)
284
+ self._session = session or httpx.AsyncClient(
285
+ timeout=timeout, headers=self._headers()
286
+ )
287
+
288
+ async def aclose(self) -> None:
289
+ await self._session.aclose()
290
+
291
+ async def __aenter__(self) -> "AsyncClient":
292
+ return self
293
+
294
+ async def __aexit__(self, *_: Any) -> None:
295
+ await self.aclose()
296
+
297
+ async def _get(self, path: str, params: dict[str, str] | None = None) -> Any:
298
+ r = await self._session.get(f"{self.base_url}{path}", params=params)
299
+ try:
300
+ body = r.json()
301
+ except ValueError:
302
+ body = r.text
303
+ if r.status_code != 200:
304
+ _raise_for_status(r.status_code, body)
305
+ return body
306
+
307
+ async def _post(self, path: str, body: dict[str, Any]) -> Any:
308
+ r = await self._session.post(
309
+ f"{self.base_url}{path}",
310
+ json=body,
311
+ headers={"Content-Type": "application/json"},
312
+ )
313
+ try:
314
+ out = r.json()
315
+ except ValueError:
316
+ out = r.text
317
+ if r.status_code not in (200, 401):
318
+ _raise_for_status(r.status_code, out)
319
+ return r.status_code, out
320
+
321
+ async def check(
322
+ self,
323
+ *,
324
+ addr: str | None = None,
325
+ id: str | None = None,
326
+ identity: str | IdentityBinding | None = None,
327
+ min_sats: int | None = None,
328
+ min_days: int | None = None,
329
+ ) -> CheckResult:
330
+ try:
331
+ body = await self._get("/api/check", self._check_params(
332
+ addr=addr, id=id, identity=identity, min_sats=min_sats, min_days=min_days
333
+ ))
334
+ except OrangeCheckError as e:
335
+ if e.status == 404 and isinstance(e.body, dict):
336
+ return CheckResult.from_json({"ok": False, **e.body})
337
+ raise
338
+ return CheckResult.from_json(body)
339
+
340
+ async def verify(
341
+ self,
342
+ *,
343
+ addr: str,
344
+ msg: str,
345
+ sig: str,
346
+ scheme: str = "bip322",
347
+ ) -> VerifyOutcome:
348
+ _, body = await self._post(
349
+ "/api/verify", {"addr": addr, "msg": msg, "sig": sig, "scheme": scheme}
350
+ )
351
+ return VerifyOutcome.from_json(body)
352
+
353
+ async def discover(
354
+ self,
355
+ *,
356
+ addr: str | None = None,
357
+ id: str | None = None,
358
+ identity: str | IdentityBinding | None = None,
359
+ limit: int | None = None,
360
+ ) -> DiscoverResult:
361
+ body = await self._get("/api/discover", self._discover_params(
362
+ addr=addr, id=id, identity=identity, limit=limit
363
+ ))
364
+ return DiscoverResult.from_json(body)
365
+
366
+ async def challenge_issue(
367
+ self,
368
+ *,
369
+ addr: str,
370
+ audience: str | None = None,
371
+ purpose: str | None = None,
372
+ ttl: int | None = None,
373
+ ) -> Challenge:
374
+ body = await self._get(
375
+ "/api/challenge",
376
+ self._challenge_params(addr=addr, audience=audience, purpose=purpose, ttl=ttl),
377
+ )
378
+ return Challenge.from_json(body)
379
+
380
+ async def challenge_verify(
381
+ self,
382
+ *,
383
+ message: str,
384
+ signature: str,
385
+ scheme: str = "bip322",
386
+ expected_nonce: str | None = None,
387
+ expected_audience: str | None = None,
388
+ expected_purpose: str | None = None,
389
+ ) -> ChallengeVerified:
390
+ body: dict[str, Any] = {
391
+ "message": message,
392
+ "signature": signature,
393
+ "scheme": scheme,
394
+ }
395
+ if expected_nonce:
396
+ body["expectedNonce"] = expected_nonce
397
+ if expected_audience:
398
+ body["expectedAudience"] = expected_audience
399
+ if expected_purpose:
400
+ body["expectedPurpose"] = expected_purpose
401
+
402
+ status, resp = await self._post("/api/challenge", body)
403
+ if status == 200 and isinstance(resp, dict) and resp.get("ok"):
404
+ return ChallengeVerified.from_json(resp)
405
+ reason = resp.get("reason") if isinstance(resp, dict) else "unknown"
406
+ raise VerificationError(
407
+ f"challenge verification failed: {reason}", status=status, body=resp
408
+ )
409
+
410
+
411
+ # Make the classes accessible under an unused-name alias so `AttestationSummary`
412
+ # shows up in the type stubs generated from this module.
413
+ _ = AttestationSummary
orangecheck/errors.py ADDED
@@ -0,0 +1,20 @@
1
+ """Exception hierarchy for the OrangeCheck Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class OrangeCheckError(Exception):
7
+ """Base class for every error raised by this SDK."""
8
+
9
+ def __init__(self, message: str, *, status: int | None = None, body: object | None = None):
10
+ super().__init__(message)
11
+ self.status = status
12
+ self.body = body
13
+
14
+
15
+ class RateLimitError(OrangeCheckError):
16
+ """Returned by the hosted API when the caller hits its rate limit."""
17
+
18
+
19
+ class VerificationError(OrangeCheckError):
20
+ """Raised by verify_challenge when the signature or nonce doesn't match."""
orangecheck/py.typed ADDED
File without changes
@@ -0,0 +1,101 @@
1
+ """
2
+ Module-level convenience wrappers.
3
+
4
+ Most callers don't need a dedicated Client instance — they just want::
5
+
6
+ from orangecheck import check
7
+ result = check(addr="bc1q...", min_sats=100_000)
8
+
9
+ Each of these functions spins up a default ``Client`` under the hood. For
10
+ long-running apps that care about connection reuse, make your own ``Client``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .client import Client
16
+ from .types import (
17
+ Challenge,
18
+ ChallengeVerified,
19
+ CheckResult,
20
+ DiscoverResult,
21
+ IdentityBinding,
22
+ VerifyOutcome,
23
+ )
24
+
25
+
26
+ def check(
27
+ *,
28
+ addr: str | None = None,
29
+ id: str | None = None,
30
+ identity: str | IdentityBinding | None = None,
31
+ min_sats: int | None = None,
32
+ min_days: int | None = None,
33
+ base_url: str | None = None,
34
+ ) -> CheckResult:
35
+ with _client(base_url) as c:
36
+ return c.check(
37
+ addr=addr, id=id, identity=identity, min_sats=min_sats, min_days=min_days
38
+ )
39
+
40
+
41
+ def verify(
42
+ *,
43
+ addr: str,
44
+ msg: str,
45
+ sig: str,
46
+ scheme: str = "bip322",
47
+ base_url: str | None = None,
48
+ ) -> VerifyOutcome:
49
+ with _client(base_url) as c:
50
+ return c.verify(addr=addr, msg=msg, sig=sig, scheme=scheme)
51
+
52
+
53
+ def discover(
54
+ *,
55
+ addr: str | None = None,
56
+ id: str | None = None,
57
+ identity: str | IdentityBinding | None = None,
58
+ limit: int | None = None,
59
+ base_url: str | None = None,
60
+ ) -> DiscoverResult:
61
+ with _client(base_url) as c:
62
+ return c.discover(addr=addr, id=id, identity=identity, limit=limit)
63
+
64
+
65
+ def challenge_issue(
66
+ *,
67
+ addr: str,
68
+ audience: str | None = None,
69
+ purpose: str | None = None,
70
+ ttl: int | None = None,
71
+ base_url: str | None = None,
72
+ ) -> Challenge:
73
+ with _client(base_url) as c:
74
+ return c.challenge_issue(addr=addr, audience=audience, purpose=purpose, ttl=ttl)
75
+
76
+
77
+ def challenge_verify(
78
+ *,
79
+ message: str,
80
+ signature: str,
81
+ scheme: str = "bip322",
82
+ expected_nonce: str | None = None,
83
+ expected_audience: str | None = None,
84
+ expected_purpose: str | None = None,
85
+ base_url: str | None = None,
86
+ ) -> ChallengeVerified:
87
+ with _client(base_url) as c:
88
+ return c.challenge_verify(
89
+ message=message,
90
+ signature=signature,
91
+ scheme=scheme,
92
+ expected_nonce=expected_nonce,
93
+ expected_audience=expected_audience,
94
+ expected_purpose=expected_purpose,
95
+ )
96
+
97
+
98
+ def _client(base_url: str | None) -> Client:
99
+ if base_url:
100
+ return Client(base_url=base_url)
101
+ return Client()
orangecheck/types.py ADDED
@@ -0,0 +1,169 @@
1
+ """Typed data classes for the OrangeCheck Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Literal
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class IdentityBinding:
11
+ """A self-asserted handle bound to a Bitcoin address."""
12
+
13
+ protocol: str
14
+ identifier: str
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class CheckResult:
19
+ """The shape returned by :func:`check`."""
20
+
21
+ ok: bool
22
+ sats: int
23
+ days: int
24
+ score: float
25
+ attestation_id: str | None = None
26
+ address: str | None = None
27
+ identities: tuple[IdentityBinding, ...] = field(default_factory=tuple)
28
+ network: Literal["mainnet", "testnet", "signet"] | None = None
29
+ reasons: tuple[str, ...] = field(default_factory=tuple)
30
+
31
+ @classmethod
32
+ def from_json(cls, d: dict[str, Any]) -> "CheckResult":
33
+ ids = tuple(
34
+ IdentityBinding(protocol=i["protocol"], identifier=i["identifier"])
35
+ for i in d.get("identities") or []
36
+ )
37
+ return cls(
38
+ ok=bool(d["ok"]),
39
+ sats=int(d.get("sats", 0)),
40
+ days=int(d.get("days", 0)),
41
+ score=float(d.get("score", 0)),
42
+ attestation_id=d.get("attestation_id"),
43
+ address=d.get("address"),
44
+ identities=ids,
45
+ network=d.get("network"),
46
+ reasons=tuple(d.get("reasons") or ()),
47
+ )
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class VerifyOutcome:
52
+ """The shape returned by :func:`verify`."""
53
+
54
+ ok: bool
55
+ codes: tuple[str, ...]
56
+ network: Literal["mainnet", "testnet", "signet"]
57
+ attestation_id: str | None = None
58
+ identities: tuple[IdentityBinding, ...] = field(default_factory=tuple)
59
+ sats_bonded: int | None = None
60
+ days_unspent: int | None = None
61
+ score: float | None = None
62
+
63
+ @classmethod
64
+ def from_json(cls, d: dict[str, Any]) -> "VerifyOutcome":
65
+ metrics = d.get("metrics") or {}
66
+ ids = tuple(
67
+ IdentityBinding(protocol=i["protocol"], identifier=i["identifier"])
68
+ for i in d.get("identities") or []
69
+ )
70
+ return cls(
71
+ ok=bool(d["ok"]),
72
+ codes=tuple(d.get("codes") or ()),
73
+ network=d.get("network", "mainnet"),
74
+ attestation_id=d.get("attestation_id"),
75
+ identities=ids,
76
+ sats_bonded=metrics.get("sats_bonded"),
77
+ days_unspent=metrics.get("days_unspent"),
78
+ score=metrics.get("score"),
79
+ )
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class AttestationSummary:
84
+ """A single entry from a :func:`discover` response."""
85
+
86
+ attestation_id: str
87
+ address: str
88
+ scheme: str
89
+ identities: tuple[IdentityBinding, ...]
90
+ issued_at: str
91
+ expires_at: str | None = None
92
+ verification_url: str | None = None
93
+ relay_hints: tuple[str, ...] = field(default_factory=tuple)
94
+
95
+ @classmethod
96
+ def from_json(cls, d: dict[str, Any]) -> "AttestationSummary":
97
+ ids = tuple(
98
+ IdentityBinding(protocol=i["protocol"], identifier=i["identifier"])
99
+ for i in d.get("identities") or []
100
+ )
101
+ return cls(
102
+ attestation_id=d["attestation_id"],
103
+ address=d["address"],
104
+ scheme=d.get("scheme", "bip322"),
105
+ identities=ids,
106
+ issued_at=d.get("issued_at", ""),
107
+ expires_at=d.get("expires_at"),
108
+ verification_url=d.get("verification_url"),
109
+ relay_hints=tuple(d.get("relay_hints") or ()),
110
+ )
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class DiscoverResult:
115
+ """The shape returned by :func:`discover`."""
116
+
117
+ count: int
118
+ total: int
119
+ attestations: tuple[AttestationSummary, ...]
120
+
121
+ @classmethod
122
+ def from_json(cls, d: dict[str, Any]) -> "DiscoverResult":
123
+ return cls(
124
+ count=int(d.get("count", 0)),
125
+ total=int(d.get("total", 0)),
126
+ attestations=tuple(
127
+ AttestationSummary.from_json(a) for a in d.get("attestations") or ()
128
+ ),
129
+ )
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class Challenge:
134
+ """Response shape from ``GET /api/challenge``."""
135
+
136
+ message: str
137
+ nonce: str
138
+ expires_at: int
139
+ expires_at_iso: str
140
+
141
+ @classmethod
142
+ def from_json(cls, d: dict[str, Any]) -> "Challenge":
143
+ return cls(
144
+ message=d["message"],
145
+ nonce=d["nonce"],
146
+ expires_at=int(d["expiresAt"]),
147
+ expires_at_iso=d.get("expiresAtIso", ""),
148
+ )
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class ChallengeVerified:
153
+ """Successful response shape from ``POST /api/challenge``."""
154
+
155
+ address: str
156
+ nonce: str
157
+ expires_at: int
158
+ audience: str | None = None
159
+ purpose: str | None = None
160
+
161
+ @classmethod
162
+ def from_json(cls, d: dict[str, Any]) -> "ChallengeVerified":
163
+ return cls(
164
+ address=d["address"],
165
+ nonce=d["nonce"],
166
+ expires_at=int(d["expiresAt"]),
167
+ audience=d.get("audience"),
168
+ purpose=d.get("purpose"),
169
+ )
@@ -0,0 +1,261 @@
1
+ Metadata-Version: 2.4
2
+ Name: orangecheck
3
+ Version: 0.1.0
4
+ Summary: Python SDK for OrangeCheck — proof of Bitcoin stake for the open web.
5
+ Project-URL: Homepage, https://ochk.io
6
+ Project-URL: Documentation, https://ochk.io/docs
7
+ Project-URL: Repository, https://github.com/orangecheck/oc-web
8
+ Project-URL: Issues, https://github.com/orangecheck/oc-web/issues
9
+ Author: OrangeCheck
10
+ License: MIT
11
+ Keywords: attestation,bip322,bitcoin,nostr,orangecheck,sybil-resistance
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Internet :: WWW/HTTP
22
+ Classifier: Topic :: Security
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: httpx>=0.25
26
+ Provides-Extra: dev
27
+ Requires-Dist: mypy>=1.0; extra == 'dev'
28
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
29
+ Requires-Dist: pytest>=7.0; extra == 'dev'
30
+ Requires-Dist: respx>=0.20; extra == 'dev'
31
+ Requires-Dist: ruff>=0.1; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # `orangecheck` — Python SDK
35
+
36
+ **Proof of Bitcoin stake for the open web.** Python SDK for OrangeCheck.
37
+
38
+ A sybil-resistance primitive any Python app can consume in one call. Works with Django, Flask, FastAPI, Starlette, or a plain script.
39
+
40
+ ```bash
41
+ pip install orangecheck
42
+ ```
43
+
44
+ ---
45
+
46
+ ## The 30-second integration
47
+
48
+ ```python
49
+ from orangecheck import check
50
+
51
+ result = check(addr="bc1q...", min_sats=100_000, min_days=30)
52
+
53
+ if result.ok:
54
+ # let them through
55
+ ...
56
+ else:
57
+ print("rejected:", result.reasons)
58
+ ```
59
+
60
+ That's it. `check()` queries the hosted `ochk.io` API, which discovers the most recent attestation for the address on Nostr, verifies the Bitcoin signature, recomputes live chain state, and compares against your thresholds.
61
+
62
+ ---
63
+
64
+ ## Load-bearing functions
65
+
66
+ ```python
67
+ from orangecheck import check, verify, discover, challenge_issue, challenge_verify
68
+
69
+ # Gate
70
+ check(addr="bc1q...", min_sats=100_000, min_days=30)
71
+ check(id="a3f5b8c2...", min_sats=100_000)
72
+ check(identity="github:alice", min_sats=50_000)
73
+
74
+ # Verify a raw attestation
75
+ verify(addr="bc1q...", msg=canonical_message, sig=signature)
76
+
77
+ # List attestations for a subject
78
+ discover(addr="bc1q...", limit=10)
79
+
80
+ # Signed-challenge auth (prove address control)
81
+ ch = challenge_issue(addr="bc1q...", audience="https://example.com")
82
+ verified = challenge_verify(message=ch.message, signature=user_sig, expected_nonce=ch.nonce)
83
+ ```
84
+
85
+ All return typed dataclasses. All raise `OrangeCheckError` (or subclasses) on transport / server errors.
86
+
87
+ ---
88
+
89
+ ## Django integration
90
+
91
+ ```python
92
+ # views.py
93
+ from django.http import HttpResponse, JsonResponse
94
+ from orangecheck import check
95
+
96
+ def gated_post(request):
97
+ addr = request.session.get("btc_address")
98
+ if not addr:
99
+ return HttpResponse(status=401)
100
+ result = check(addr=addr, min_sats=100_000, min_days=30)
101
+ if not result.ok:
102
+ return JsonResponse({"error": "orangecheck", "reasons": result.reasons}, status=403)
103
+ # ... proceed
104
+ return JsonResponse({"ok": True})
105
+ ```
106
+
107
+ ### As middleware
108
+
109
+ ```python
110
+ # orangecheck_middleware.py
111
+ from django.http import JsonResponse
112
+ from orangecheck import check
113
+
114
+ class OrangeCheckMiddleware:
115
+ def __init__(self, get_response):
116
+ self.get_response = get_response
117
+
118
+ def __call__(self, request):
119
+ if request.path.startswith("/protected/"):
120
+ addr = request.session.get("btc_address")
121
+ r = check(addr=addr, min_sats=100_000, min_days=30)
122
+ if not r.ok:
123
+ return JsonResponse({"error": r.reasons}, status=403)
124
+ return self.get_response(request)
125
+ ```
126
+
127
+ ---
128
+
129
+ ## FastAPI integration
130
+
131
+ ```python
132
+ from fastapi import FastAPI, Depends, HTTPException
133
+ from orangecheck import AsyncClient
134
+
135
+ app = FastAPI()
136
+ oc = AsyncClient()
137
+
138
+ async def require_stake(addr: str, min_sats: int = 100_000, min_days: int = 30):
139
+ r = await oc.check(addr=addr, min_sats=min_sats, min_days=min_days)
140
+ if not r.ok:
141
+ raise HTTPException(status_code=403, detail={"reasons": list(r.reasons)})
142
+ return r
143
+
144
+ @app.post("/post")
145
+ async def post_comment(gate = Depends(require_stake)):
146
+ return {"ok": True, "sats": gate.sats}
147
+ ```
148
+
149
+ ---
150
+
151
+ ## Flask integration
152
+
153
+ ```python
154
+ from functools import wraps
155
+ from flask import Flask, request, jsonify
156
+ from orangecheck import check
157
+
158
+ app = Flask(__name__)
159
+
160
+ def require_orangecheck(min_sats=100_000, min_days=30):
161
+ def decorator(fn):
162
+ @wraps(fn)
163
+ def wrapper(*args, **kwargs):
164
+ addr = request.headers.get("X-OC-Address")
165
+ r = check(addr=addr, min_sats=min_sats, min_days=min_days)
166
+ if not r.ok:
167
+ return jsonify(error="orangecheck", reasons=r.reasons), 403
168
+ return fn(*args, **kwargs)
169
+ return wrapper
170
+ return decorator
171
+
172
+ @app.post("/post")
173
+ @require_orangecheck(min_sats=100_000, min_days=30)
174
+ def post_comment():
175
+ return {"ok": True}
176
+ ```
177
+
178
+ ---
179
+
180
+ ## Async
181
+
182
+ Every function has a sync counterpart on `AsyncClient`:
183
+
184
+ ```python
185
+ import asyncio
186
+ from orangecheck import AsyncClient
187
+
188
+ async def main():
189
+ async with AsyncClient() as oc:
190
+ r = await oc.check(addr="bc1q...", min_sats=100_000)
191
+ print(r.ok, r.sats, r.days)
192
+
193
+ asyncio.run(main())
194
+ ```
195
+
196
+ ---
197
+
198
+ ## Configuration
199
+
200
+ ```python
201
+ from orangecheck import Client
202
+
203
+ # Default — hits https://ochk.io
204
+ c = Client()
205
+
206
+ # Self-hosted verifier, custom timeout
207
+ c = Client(base_url="https://verifier.mycompany.com", timeout=5.0)
208
+
209
+ # Reuse an existing httpx Client (connection pooling, auth, etc.)
210
+ import httpx
211
+ my_session = httpx.Client(proxies="http://proxy.example.com")
212
+ c = Client(session=my_session)
213
+ ```
214
+
215
+ ---
216
+
217
+ ## Types
218
+
219
+ Every response is a frozen `dataclass` with predictable fields:
220
+
221
+ ```python
222
+ @dataclass(frozen=True)
223
+ class CheckResult:
224
+ ok: bool
225
+ sats: int
226
+ days: int
227
+ score: float
228
+ attestation_id: str | None
229
+ address: str | None
230
+ identities: tuple[IdentityBinding, ...]
231
+ network: Literal["mainnet", "testnet", "signet"] | None
232
+ reasons: tuple[str, ...]
233
+ ```
234
+
235
+ Full type information ships with the package (`py.typed` marker, tested with mypy strict).
236
+
237
+ ---
238
+
239
+ ## Errors
240
+
241
+ - **`OrangeCheckError`** — base class for everything else.
242
+ - **`RateLimitError`** — the hosted API returned 429.
243
+ - **`VerificationError`** — `challenge_verify` failed (bad signature, expired, nonce mismatch, …).
244
+
245
+ `check()` treats 404 (no attestation found) as `CheckResult(ok=False, reasons=("not_found",))` rather than raising — callers should gate on `.ok`, not on try/except.
246
+
247
+ ---
248
+
249
+ ## Shell smoke-test
250
+
251
+ ```bash
252
+ python -m orangecheck check --addr bc1q... --min-sats 100000
253
+ ```
254
+
255
+ Exits `0` on pass, `2` on fail. Prefer the TypeScript `oc` CLI for richer output; this one is here mostly to verify the install.
256
+
257
+ ---
258
+
259
+ ## License
260
+
261
+ MIT. The OrangeCheck protocol is CC-BY-4.0.
@@ -0,0 +1,11 @@
1
+ orangecheck/__init__.py,sha256=-IF763Cbydc4v1occEk2ANezCMS86jBGICxdsdmm5pI,1555
2
+ orangecheck/__main__.py,sha256=h8qj5Ok8NFU1z-TLsqLI_A_Y_VkqF8C26UQ_xb1PpbE,2027
3
+ orangecheck/client.py,sha256=qA-hrLr5NlNtDX0GoaI3VyKfRbua1_V426BqlaiLwZA,12962
4
+ orangecheck/errors.py,sha256=WI8tbTtge8UGkGB5NrjSLo-Q5RaqwcIxuJWVrPQVt1A,614
5
+ orangecheck/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ orangecheck/top_level.py,sha256=TYlbG7TOFeIweHAsDnA5mSRR_q7C_oh6a9CFJ9bzU4Q,2542
7
+ orangecheck/types.py,sha256=uCgo3UDNRrJLbZOvMLJx2WEOqv8ND7feCS1_kuZSsqk,4914
8
+ orangecheck-0.1.0.dist-info/METADATA,sha256=mNxjRy4xJ81LgsjasVQXN0T4Kr0Hhe0zqk9HzRQ_rRo,7066
9
+ orangecheck-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ orangecheck-0.1.0.dist-info/entry_points.txt,sha256=UbQiJ3mKP-WqYB_gHTM4JZSkoWnaLQSCeJQj5ssst_E,58
11
+ orangecheck-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ orangecheck = orangecheck.__main__:main