orangecheck — Python SDK
Proof of Bitcoin stake for the open web. Python SDK for OrangeCheck.
A sybil-resistance primitive any Python app can consume in one call. Works with Django, Flask, FastAPI, Starlette, or a plain script.
pip install orangecheck
The 30-second integration
from orangecheck import check
result = check(addr="bc1q...", min_sats=100_000, min_days=30)
if result.ok:
# let them through
...
else:
print("rejected:", result.reasons)
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.
Load-bearing functions
from orangecheck import check, verify, discover, challenge_issue, challenge_verify
# Gate
check(addr="bc1q...", min_sats=100_000, min_days=30)
check(id="a3f5b8c2...", min_sats=100_000)
check(identity="github:alice", min_sats=50_000)
# Verify a raw attestation
verify(addr="bc1q...", msg=canonical_message, sig=signature)
# List attestations for a subject
discover(addr="bc1q...", limit=10)
# Signed-challenge auth (prove address control)
ch = challenge_issue(addr="bc1q...", audience="https://example.com")
verified = challenge_verify(message=ch.message, signature=user_sig, expected_nonce=ch.nonce)
All return typed dataclasses. All raise OrangeCheckError (or subclasses) on transport / server errors.
Django integration
# views.py
from django.http import HttpResponse, JsonResponse
from orangecheck import check
def gated_post(request):
addr = request.session.get("btc_address")
if not addr:
return HttpResponse(status=401)
result = check(addr=addr, min_sats=100_000, min_days=30)
if not result.ok:
return JsonResponse({"error": "orangecheck", "reasons": result.reasons}, status=403)
# ... proceed
return JsonResponse({"ok": True})
As middleware
# orangecheck_middleware.py
from django.http import JsonResponse
from orangecheck import check
class OrangeCheckMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.path.startswith("/protected/"):
addr = request.session.get("btc_address")
r = check(addr=addr, min_sats=100_000, min_days=30)
if not r.ok:
return JsonResponse({"error": r.reasons}, status=403)
return self.get_response(request)
FastAPI integration
from fastapi import FastAPI, Depends, HTTPException
from orangecheck import AsyncClient
app = FastAPI()
oc = AsyncClient()
async def require_stake(addr: str, min_sats: int = 100_000, min_days: int = 30):
r = await oc.check(addr=addr, min_sats=min_sats, min_days=min_days)
if not r.ok:
raise HTTPException(status_code=403, detail={"reasons": list(r.reasons)})
return r
@app.post("/post")
async def post_comment(gate = Depends(require_stake)):
return {"ok": True, "sats": gate.sats}
Flask integration
from functools import wraps
from flask import Flask, request, jsonify
from orangecheck import check
app = Flask(__name__)
def require_orangecheck(min_sats=100_000, min_days=30):
def decorator(fn):
@wraps(fn)
def wrapper(*args, **kwargs):
addr = request.headers.get("X-OC-Address")
r = check(addr=addr, min_sats=min_sats, min_days=min_days)
if not r.ok:
return jsonify(error="orangecheck", reasons=r.reasons), 403
return fn(*args, **kwargs)
return wrapper
return decorator
@app.post("/post")
@require_orangecheck(min_sats=100_000, min_days=30)
def post_comment():
return {"ok": True}
Async
Every function has a sync counterpart on AsyncClient:
import asyncio
from orangecheck import AsyncClient
async def main():
async with AsyncClient() as oc:
r = await oc.check(addr="bc1q...", min_sats=100_000)
print(r.ok, r.sats, r.days)
asyncio.run(main())
ochk.app sign-in, for your backend
A site whose people sign in with ochk.app verifies the token the popup hands
back, signs billable events and checks webhooks with three functions. They
speak the same wire formats as @orangecheck/me-client.
import json, httpx
from orangecheck import verify_session, sign_event, verify_webhook, InvalidSession, SignInPolicy
# The origin of the page that opened the popup, exactly.
session = verify_session(token, audience="https://your-site.example")
session.did_oc # who signed in
# Who may sign in: your project's requirement, set on ochk.app (2.9.0 or later).
# BondRequired (an InvalidSession) carries need, have and back_url.
POLICY = SignInPolicy(PROJECT_KEY)
session = verify_session(token, audience="https://your-site.example", policy=POLICY)
# Or in code, which wins over the project's: require_bond={"min_sats": 100000, "min_days": 30}
body = json.dumps({"project_key": PROJECT_KEY, "subtype": "account_created",
"user_address": session.did_oc})
httpx.post("https://me.ochk.io/api/integrator/event", content=body, headers={
"Content-Type": "application/json",
"X-OC-Signature": sign_event(SIGNING_SECRET, body),
})
event = verify_webhook(request.headers, raw_body) # raises InvalidWebhook
A token verifies until it expires, up to 30 days. To refuse one the person
ended everywhere (signed out everywhere, removed a passkey, deleted the
account), keep one RevocationList (2.8.0 or later) per process and pass it with the request's
method. It reads me.ochk.io's list of ended sessions for the token's origin at most
once a minute, signed with your secret:
from orangecheck import RevocationList, SessionEnded
REVOCATIONS = RevocationList(PROJECT_KEY, SIGNING_SECRET)
session = verify_session(token, audience="https://your-site.example",
revocations=REVOCATIONS, method=request.method)
# SessionEnded (an InvalidSession) when the person ended it. While the list
# can't be read, reads go on and writes raise reason "revocations_unavailable".
# If your own session replaces the token, keep its rh, aud and iat with that
# session and ask REVOCATIONS.status(rh, aud, iat): "revoked" ends it.
BondRequired and SessionEnded are both InvalidSessions, so catch them
before InvalidSession, or that handler takes them too.
The key set, the policy and the list are each fetched inside the call that first
needs them. To keep those fetches off your requests, warm them at startup and
every ten seconds from a background thread in each process (2.10.0 or later). One
SignInPolicy and one RevocationList are safe to share between threads.
import threading, time
from orangecheck import warm_session
def keep_warm() -> None:
while True:
warm_session("https://your-site.example", revocations=REVOCATIONS, policy=POLICY)
time.sleep(10)
threading.Thread(target=keep_warm, daemon=True).start()
The full guide is at ochk.io/docs/sdk/python.
Configuration
from orangecheck import Client
# Default — hits https://ochk.io
c = Client()
# Self-hosted verifier, custom timeout
c = Client(base_url="https://verifier.mycompany.com", timeout=5.0)
# Reuse an existing httpx Client (connection pooling, auth, etc.)
import httpx
my_session = httpx.Client(proxy="http://proxy.example.com")
c = Client(session=my_session)
Types
Every response is a frozen dataclass with predictable fields:
@dataclass(frozen=True)
class CheckResult:
ok: bool
sats: int
days: int
score: float
attestation_id: str | None
address: str | None
identities: tuple[IdentityBinding, ...]
network: Literal["mainnet", "testnet", "signet"] | None
reasons: tuple[str, ...]
Full type information ships with the package (py.typed marker, tested with mypy strict).
Errors
OrangeCheckError— base class for everything else.RateLimitError— the hosted API returned 429.VerificationError—challenge_verifyfailed (bad signature, expired, nonce mismatch, …).
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.
Shell smoke-test
python -m orangecheck check --addr bc1q... --min-sats 100000
Exits 0 on pass, 2 on fail. Prefer the TypeScript oc CLI for richer output; this one is here mostly to verify the install.
License
MIT. The OrangeCheck protocol specifications are MIT too.
Metadata
Release files for orangecheck 2.10.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| orangecheck-2.10.1.tar.gz | 113.3 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| orangecheck-2.10.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 173.1 kB
Release files / orangecheck-2.10.1.tar.gz
| Download URL | orangecheck-2.10.1.tar.gz |
|---|---|
| Size | 113.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
32d26bf6aa6f2eefc082a388554855ae71083ec54d26910d0cdfd2b6c0a67679
|
|
BLAKE2b-256 checksum How to use checksums |
f296585c413da1c2ccd6ad16829db0e2f9b88db264b2d631112361d7957a6383
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Release files / orangecheck-2.10.1-py3-none-any.whl
| Download URL | orangecheck-2.10.1-py3-none-any.whl |
|---|---|
| Size | 59.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
99fc737fb37fab4d8434228009e33cc74a58a41d49ec7f9211fd9e4f24cec505
|
|
BLAKE2b-256 checksum How to use checksums |
6f08c45492529ee93e015ecbb3b0e2511d84b1018db53faace32814d0bcf4a0b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Security for orangecheck 2.10.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.