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.
- orangecheck/__init__.py +60 -0
- orangecheck/__main__.py +68 -0
- orangecheck/client.py +413 -0
- orangecheck/errors.py +20 -0
- orangecheck/py.typed +0 -0
- orangecheck/top_level.py +101 -0
- orangecheck/types.py +169 -0
- orangecheck-0.1.0.dist-info/METADATA +261 -0
- orangecheck-0.1.0.dist-info/RECORD +11 -0
- orangecheck-0.1.0.dist-info/WHEEL +4 -0
- orangecheck-0.1.0.dist-info/entry_points.txt +2 -0
orangecheck/__init__.py
ADDED
|
@@ -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"
|
orangecheck/__main__.py
ADDED
|
@@ -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
|
orangecheck/top_level.py
ADDED
|
@@ -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,,
|