--- title: "wallet sample: Python + FastAPI" description: "Operator Contract v1.4.1 wallet module, Python + FastAPI. Generated from the contract." --- # wallet: Python + FastAPI The seamless wallet OneHazel calls on behalf of every game studio: session start and exchange, balance, bet, win, rollback and free rounds. Generated from Operator Contract v1.4.1. Key `python/fastapi/wallet`; the `connect_samples` MCP tool returns this page for that key. ## What it implements | Operation | Purpose | Idempotency key | |---|---|---| | `POST /session/start` | Mint a single-use launch token for a player and game. | yes | | `POST /session/exchange` | Exchange a launch token for a wallet-capable session token. | yes | | `POST /session/balance` | Read the live balance for a session. | no | | `POST /wallet/bet` | Debit a real-money stake. | yes | | `POST /wallet/win` | Credit a win. | yes | | `POST /wallet/rollback` | Reverse a prior bet or win. | yes | | `POST /freerounds/issue` | Record a free-round grant (system of record stays with the operator). | yes | | `POST /freerounds/cancel` | Cancel an active grant. OneHazel also calls this to compensate a failed studio-side issue. | yes | | `POST /freerounds/settle` | Close a grant and credit its lump-sum winnings. | yes | | `POST /freerounds/get` | Read a grant's current state. | no | Every request is checked in this order: the `Authorization: Bearer` callback key, then `X-OneHazel-Signature` (`t=,v1=`, HMAC-SHA256 of `"." + raw body`, 300 s tolerance, constant-time compare) over the RAW body before it is parsed, then `X-OneHazel-Env`. `test` traffic goes to a separate test ledger and never touches the live one. Errors use the flat `{ code, message, retryable }` envelope; money is integer minor units. Money-moving calls are idempotent: a replay of the same `Idempotency-Key` returns the ORIGINAL response with `duplicate: true`; the same key with a different body is `409 idempotency_key_conflict`. ## Run it ```bash pip install fastapi uvicorn ONEHAZEL_CALLBACK_KEY= ONEHAZEL_SIGNING_SECRET= uvicorn onehazel_wallet:app --port 3000 # base URL to register: https:///onehazel/v1 (ONEHAZEL_BASE_PATH changes the path) ``` Environment: `ONEHAZEL_CALLBACK_KEY` and `ONEHAZEL_SIGNING_SECRET` are the values `connect_register_endpoint` showed you once. During a secret rotation set `ONEHAZEL_SIGNING_SECRET_PREVIOUS` to the old secret for the overlap hour. `ONEHAZEL_TEST_PLAYER_ID` (default `test-player-1`) is the funded player on the test ledger. ## Code `onehazel_wallet.py` ```python """OneHazel wallet module, Operator Contract v1.4.1. Generated; regenerate rather than edit.""" from __future__ import annotations import hashlib import hmac import json import os import re import secrets import sys import threading import time from typing import Any, Callable, Optional from fastapi import FastAPI, Request from fastapi.responses import JSONResponse # Generated from the OneHazel Operator Contract v1.4.1: operations, request fields, # error codes with their HTTP status, and the signature tolerance. Do not hand-edit; regenerate. CONTRACT: dict[str, Any] = json.loads(r'''{ "version": "1.4.1", "auth": { "scheme": "timestamped", "bearer": true, "envHeader": true }, "toleranceSec": 300, "errorStatus": { "unauthorized": 401, "signature_invalid": 401, "idempotency_key_conflict": 409, "launch_token_consumed": 409, "round_closed": 409, "currency_unsupported": 422, "currency_mismatch": 422, "player_blocked": 422, "rollback_amount_mismatch": 422, "insufficient_funds": 402, "internal_error": 500, "unavailable": 500, "invalid_request": 400, "session_unknown": 404, "session_expired": 404, "launch_token_unknown": 404, "launch_token_expired": 404, "player_unknown": 404, "game_unknown": 404, "transaction_unknown": 404, "freeround_unknown": 404 }, "retryable": [ "internal_error", "unavailable" ], "ops": [ { "path": "/session/start", "operationId": "sessionStart", "summary": "Mint a single-use launch token for a player and game.", "idempotent": true, "fields": [ { "name": "idempotency_key", "type": "string", "required": true, "minLength": 1, "maxLength": 128 }, { "name": "player_external_id", "type": "string", "required": true, "minLength": 1 }, { "name": "provider_slug", "type": "string", "required": true }, { "name": "provider_game_code", "type": "string", "required": true, "minLength": 1 }, { "name": "currency", "type": "string", "required": true, "pattern": "^[A-Z][A-Z0-9]{2,9}$" }, { "name": "language", "type": "string", "required": true }, { "name": "client_type", "type": "string", "required": true, "enum": [ "DESKTOP", "MOBILE" ] }, { "name": "return_url", "type": "string", "required": false } ], "closed": true }, { "path": "/session/exchange", "operationId": "sessionExchange", "summary": "Exchange a launch token for a wallet-capable session token.", "idempotent": true, "fields": [ { "name": "idempotency_key", "type": "string", "required": true, "minLength": 1, "maxLength": 128 }, { "name": "launch_token", "type": "string", "required": true, "minLength": 1 } ], "closed": true }, { "path": "/session/balance", "operationId": "sessionBalance", "summary": "Read the live balance for a session.", "idempotent": false, "fields": [ { "name": "session_token", "type": "string", "required": true, "minLength": 1 } ], "closed": true }, { "path": "/wallet/bet", "operationId": "walletBet", "summary": "Debit a real-money stake.", "idempotent": true, "fields": [ { "name": "idempotency_key", "type": "string", "required": true, "minLength": 1, "maxLength": 128 }, { "name": "session_token", "type": "string", "required": true, "minLength": 1 }, { "name": "round_external_id", "type": "string", "required": true, "minLength": 1 }, { "name": "amount_minor", "type": "integer", "required": true, "minimum": 0, "exclusiveMinimum": 0 }, { "name": "currency", "type": "string", "required": true, "pattern": "^[A-Z][A-Z0-9]{2,9}$" }, { "name": "provider_transaction_id", "type": "string", "required": true, "minLength": 1 } ], "closed": true }, { "path": "/wallet/win", "operationId": "walletWin", "summary": "Credit a win.", "idempotent": true, "fields": [ { "name": "idempotency_key", "type": "string", "required": true, "minLength": 1, "maxLength": 128 }, { "name": "session_token", "type": "string", "required": true, "minLength": 1 }, { "name": "round_external_id", "type": "string", "required": true, "minLength": 1 }, { "name": "amount_minor", "type": "integer", "required": true, "minimum": 0, "exclusiveMinimum": 0 }, { "name": "currency", "type": "string", "required": true, "pattern": "^[A-Z][A-Z0-9]{2,9}$" }, { "name": "provider_transaction_id", "type": "string", "required": true, "minLength": 1 }, { "name": "round_closed", "type": "boolean", "required": false }, { "name": "bet_idempotency_key", "type": "string", "required": false } ], "closed": true }, { "path": "/wallet/rollback", "operationId": "walletRollback", "summary": "Reverse a prior bet or win.", "idempotent": true, "fields": [ { "name": "idempotency_key", "type": "string", "required": true, "minLength": 1, "maxLength": 128 }, { "name": "session_token", "type": "string", "required": true, "minLength": 1 }, { "name": "target_idempotency_key", "type": "string", "required": true, "minLength": 1 }, { "name": "round_external_id", "type": "string", "required": false }, { "name": "provider_transaction_id", "type": "string", "required": true, "minLength": 1 }, { "name": "metadata", "type": "object", "required": true, "fields": [ { "name": "amount_minor", "type": "integer", "required": true, "minimum": 0 } ] } ], "closed": true }, { "path": "/freerounds/issue", "operationId": "freeroundsIssue", "summary": "Record a free-round grant (system of record stays with the operator).", "idempotent": true, "fields": [ { "name": "idempotency_key", "type": "string", "required": true, "minLength": 1, "maxLength": 128 }, { "name": "player_external_id", "type": "string", "required": true, "minLength": 1 }, { "name": "provider_slug", "type": "string", "required": true }, { "name": "provider_game_codes", "type": "array", "required": true }, { "name": "count", "type": "integer", "required": true, "minimum": 1 }, { "name": "value_minor", "type": "integer", "required": true, "minimum": 0 }, { "name": "currency", "type": "string", "required": true, "pattern": "^[A-Z][A-Z0-9]{2,9}$" }, { "name": "valid_from", "type": "string", "required": false }, { "name": "valid_until", "type": "string", "required": false }, { "name": "bet_mode", "type": "string", "required": false } ], "closed": true }, { "path": "/freerounds/cancel", "operationId": "freeroundsCancel", "summary": "Cancel an active grant. OneHazel also calls this to compensate a failed studio-side issue.", "idempotent": true, "fields": [ { "name": "idempotency_key", "type": "string", "required": true, "minLength": 1, "maxLength": 128 }, { "name": "freeround_external_id", "type": "string", "required": true, "minLength": 1 } ], "closed": true }, { "path": "/freerounds/settle", "operationId": "freeroundsSettle", "summary": "Close a grant and credit its lump-sum winnings.", "idempotent": true, "fields": [ { "name": "idempotency_key", "type": "string", "required": true, "minLength": 1, "maxLength": 128 }, { "name": "freeround_external_id", "type": "string", "required": true, "minLength": 1 }, { "name": "rounds_played", "type": "integer", "required": true, "minimum": 0 }, { "name": "total_bet_minor", "type": "integer", "required": true, "minimum": 0 }, { "name": "total_win_minor", "type": "integer", "required": true, "minimum": 0 }, { "name": "final_status", "type": "string", "required": true, "enum": [ "consumed", "expired" ] } ], "closed": true }, { "path": "/freerounds/get", "operationId": "freeroundsGet", "summary": "Read a grant's current state.", "idempotent": false, "fields": [ { "name": "freeround_external_id", "type": "string", "required": true, "minLength": 1 } ], "closed": true } ] }''') # ───────────────────────────── configuration ───────────────────────────── def _required_env(name: str) -> str: value = os.environ.get(name, "") if not value: # Never print the value of a secret, only its name. print(f"{name} is not set", file=sys.stderr) sys.exit(1) return value # During a rotation OneHazel signs with the new secret; the old one stays valid for 1 hour. SIGNING_SECRETS = [s for s in (_required_env("ONEHAZEL_SIGNING_SECRET"), os.environ.get("ONEHAZEL_SIGNING_SECRET_PREVIOUS", "")) if s] BASE_PATH = os.environ.get("ONEHAZEL_BASE_PATH", "/onehazel/v1").rstrip("/") Reply = tuple[int, dict[str, Any]] Header = Callable[[str], Optional[str]] # ───────────────────────────── errors ───────────────────────────── class ContractError(Exception): """Raised anywhere in a handler; becomes the flat {code, message, retryable} envelope.""" def __init__(self, code: str, message: str) -> None: super().__init__(message) self.code = code self.message = message def error_reply(code: str, message: str) -> Reply: return CONTRACT["errorStatus"][code], {"code": code, "message": message, "retryable": code in CONTRACT["retryable"]} # ───────────────────────────── request validation ───────────────────────────── _TYPES: dict[str, Callable[[Any], bool]] = { "integer": lambda v: isinstance(v, int) and not isinstance(v, bool), "string": lambda v: isinstance(v, str), "boolean": lambda v: isinstance(v, bool), "array": lambda v: isinstance(v, list), "object": lambda v: isinstance(v, dict), } def check_fields(value: Any, fields: list[dict[str, Any]], closed: bool, where: str) -> dict[str, Any]: if not isinstance(value, dict): raise ContractError("invalid_request", f"{where or 'body'} must be a JSON object") if closed: known = {f["name"] for f in fields} for key in value: if key not in known: raise ContractError("invalid_request", f"unknown field {where}{key}") for f in fields: name = where + f["name"] v = value.get(f["name"]) if v is None: if f["required"]: raise ContractError("invalid_request", f"{name} is required") continue if not _TYPES.get(f["type"], lambda _v: True)(v): raise ContractError("invalid_request", f"{name} must be {f['type']}") if isinstance(v, int) and not isinstance(v, bool): if "minimum" in f and v < f["minimum"]: raise ContractError("invalid_request", f"{name} is below {f['minimum']}") if "exclusiveMinimum" in f and v <= f["exclusiveMinimum"]: raise ContractError("invalid_request", f"{name} must be greater than {f['exclusiveMinimum']}") if isinstance(v, str): if "minLength" in f and len(v) < f["minLength"]: raise ContractError("invalid_request", f"{name} is too short") if "maxLength" in f and len(v) > f["maxLength"]: raise ContractError("invalid_request", f"{name} is too long") if "pattern" in f and not re.search(f["pattern"], v): raise ContractError("invalid_request", f"{name} is malformed") if "enum" in f and v not in f["enum"]: raise ContractError("invalid_request", f"{name} is not an allowed value") if "fields" in f: check_fields(v, f["fields"], False, f"{name}.") return value def parse_body(raw_body: bytes) -> Any: try: return json.loads(raw_body) except ValueError: raise ContractError("invalid_request", "body is not valid JSON") from None def find_op(path: str) -> Optional[dict[str, Any]]: return next((o for o in CONTRACT["ops"] if o["path"] == path), None) # ───────────────────────────── OneHazel-to-operator call authentication ───────────────────────────── CALLBACK_KEY = _required_env("ONEHAZEL_CALLBACK_KEY") TEST_PLAYER_ID = os.environ.get("ONEHAZEL_TEST_PLAYER_ID", "test-player-1") TEST_PLAYER_BALANCE = int(os.environ.get("ONEHAZEL_TEST_PLAYER_BALANCE", "100000")) TEST_PLAYER_CURRENCY = os.environ.get("ONEHAZEL_TEST_PLAYER_CURRENCY", "EUR") def verify_signature(raw_body: bytes, header: Optional[str], keys: list[str], tolerance_sec: int, now: Optional[int] = None) -> bool: """X-OneHazel-Signature: t=,v1=." + raw body)>. Runs over the RAW bytes, before the body is parsed. Constant-time compare. """ if not header: return False t: Optional[int] = None macs: list[str] = [] for part in header.split(","): k, _, v = part.strip().partition("=") if k == "t" and v.isdigit(): t = int(v) if k == "v1" and re.fullmatch(r"[0-9a-f]{64}", v): macs.append(v) now = int(time.time()) if now is None else now if t is None or abs(now - t) > tolerance_sec or not macs: return False for key in keys: expected = hmac.new(key.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest() if any(hmac.compare_digest(expected, mac) for mac in macs): return True return False def authenticate(header: Header, raw_body: bytes) -> str: """Bearer, then signature over the raw body, then X-OneHazel-Env. Returns "test" or "live".""" if not hmac.compare_digest((header("authorization") or "").encode(), f"Bearer {CALLBACK_KEY}".encode()): raise ContractError("unauthorized", "missing or wrong callback key") if not verify_signature(raw_body, header("x-onehazel-signature"), SIGNING_SECRETS, int(CONTRACT["toleranceSec"])): raise ContractError("signature_invalid", "bad or stale X-OneHazel-Signature") env = header("x-onehazel-env") if env not in ("test", "live"): raise ContractError("invalid_request", "X-OneHazel-Env must be test or live") return env # ───────────────────────────── ledgers ───────────────────────────── # # One ledger per X-OneHazel-Env. Conformance runs always send `test`, so they only # ever touch the test ledger. This sample keeps both in memory; replace `live` with # your real wallet and keep every store (idempotency included) in your database. # Every code in the contract's currency table (x-onehazel-currency-exponents). amount_minor is in # the currency's own minor unit (JPY 0 decimals, EUR 2, BHD 3, USDT 6), so a wallet never rescales: # it only checks the code is known and matches the player's wallet. Amounts are never rounded. KNOWN_CURRENCIES = frozenset(["AED","AFN","ALL","AMD","AOA","ARS","AUD","AWG","AZN","BAM","BBD","BDT","BGN","BHD","BIF","BMD","BND","BOB","BOV","BRL","BSD","BTC","BTN","BWP","BYN","BZD","CAD","CDF","CHE","CHF","CHW","CLP","CNY","COP","COU","CRC","CUP","CVE","CZK","DJF","DKK","DOP","DZD","EGP","ERN","ETB","ETH","EUR","FJD","FKP","GBP","GEL","GHS","GIP","GMD","GNF","GTQ","GYD","HKD","HNL","HTG","HUF","IDR","ILS","INR","IQD","IRR","ISK","JMD","JOD","JPY","KES","KGS","KHR","KMF","KPW","KRW","KWD","KYD","KZT","LAK","LBP","LKR","LRD","LSL","LYD","MAD","MDL","MGA","MKD","MMK","MNT","MOP","MRU","MUR","MVR","MWK","MXN","MXV","MYR","MZN","NAD","NGN","NIO","NOK","NPR","NZD","OMR","PAB","PEN","PGK","PHP","PKR","PLN","PYG","QAR","RON","RSD","RUB","RWF","SAR","SBD","SCR","SDG","SEK","SGD","SHP","SLE","SOS","SRD","SSP","STN","SVC","SYP","SZL","THB","TJS","TMT","TND","TOP","TRY","TTD","TWD","TZS","UAH","UGX","USD","USDT","USN","UYU","UZS","VED","VES","VND","VUV","WST","XAF","XCD","XCG","XOF","XPF","YER","ZAR","ZMW","ZWG"]) def _new_ledger(players: list[dict[str, Any]]) -> dict[str, Any]: return { "players": {p["external_id"]: p for p in players}, "launch_tokens": {}, "sessions": {}, "transactions": {}, "closed_rounds": set(), "freerounds": {}, "idempotency": {}, } LEDGERS: dict[str, dict[str, Any]] = { "test": _new_ledger([ {"external_id": TEST_PLAYER_ID, "currency": TEST_PLAYER_CURRENCY, "balance_minor": TEST_PLAYER_BALANCE, "country": "MT", "status": "active", "name": "Test Player"}, ]), # Replace with your real wallet. Never seed real-money players from test data. "live": _new_ledger([]), } _LOCK = threading.Lock() def _id(prefix: str) -> str: return prefix + secrets.token_hex(16) def _player(ledger: dict[str, Any], player_id: str) -> dict[str, Any]: player = ledger["players"].get(player_id) if player is None: raise ContractError("player_unknown", "unknown player") return player def _session_player(ledger: dict[str, Any], token: str) -> dict[str, Any]: player_id = ledger["sessions"].get(token) if player_id is None: raise ContractError("session_unknown", "unknown session") return _player(ledger, player_id) def _check_currency(player: dict[str, Any], currency: str) -> None: if currency not in KNOWN_CURRENCIES: raise ContractError("invalid_request", f"currency {currency} is not in the Operator Contract currency table") if currency != player["currency"]: raise ContractError("currency_mismatch", "currency does not match the player's wallet") def _wallet_reply(status: str, player: dict[str, Any]) -> Reply: return 200, { "status": status, "player_external_id": player["external_id"], "currency": player["currency"], "balance_minor": player["balance_minor"], "transaction_external_id": _id("tx_"), "duplicate": False, } # ───────────────────────────── operations ───────────────────────────── def session_start(ledger: dict[str, Any], b: dict[str, Any]) -> Reply: player = _player(ledger, b["player_external_id"]) if player["status"] != "active": raise ContractError("player_blocked", "player cannot play") _check_currency(player, b["currency"]) token = _id("lt_") ledger["launch_tokens"][token] = {"player": player["external_id"], "consumed": False} return 200, { "launch_token": {"token": token}, "player": {"external_id": player["external_id"], "currency": player["currency"], "balance_minor": player["balance_minor"]}, } def session_exchange(ledger: dict[str, Any], b: dict[str, Any]) -> Reply: launch = ledger["launch_tokens"].get(b["launch_token"]) if launch is None: raise ContractError("launch_token_unknown", "unknown launch token") # A replay with the same idempotency key never reaches here: it gets the original session. if launch["consumed"]: raise ContractError("launch_token_consumed", "launch token already exchanged") launch["consumed"] = True player = _player(ledger, launch["player"]) token = _id("st_") ledger["sessions"][token] = player["external_id"] return 200, { "session": {"token": token}, "player": { "external_id": player["external_id"], "currency": player["currency"], "balance_minor": player["balance_minor"], "name": player.get("name"), "country": player["country"], }, } def session_balance(ledger: dict[str, Any], b: dict[str, Any]) -> Reply: player = _session_player(ledger, b["session_token"]) return 200, {"player_external_id": player["external_id"], "currency": player["currency"], "balance_minor": player["balance_minor"]} def wallet_bet(ledger: dict[str, Any], b: dict[str, Any]) -> Reply: player = _session_player(ledger, b["session_token"]) _check_currency(player, b["currency"]) if player["status"] != "active": raise ContractError("player_blocked", "player cannot play") if b["round_external_id"] in ledger["closed_rounds"]: raise ContractError("round_closed", "round is closed") if b["amount_minor"] > player["balance_minor"]: raise ContractError("insufficient_funds", "balance too low") player["balance_minor"] -= b["amount_minor"] ledger["transactions"][b["idempotency_key"]] = {"kind": "bet", "player": player["external_id"], "amount_minor": b["amount_minor"]} return _wallet_reply("accepted", player) def wallet_win(ledger: dict[str, Any], b: dict[str, Any]) -> Reply: player = _session_player(ledger, b["session_token"]) _check_currency(player, b["currency"]) player["balance_minor"] += b["amount_minor"] ledger["transactions"][b["idempotency_key"]] = {"kind": "win", "player": player["external_id"], "amount_minor": b["amount_minor"]} if b.get("round_closed") is True: ledger["closed_rounds"].add(b["round_external_id"]) return _wallet_reply("accepted", player) def wallet_rollback(ledger: dict[str, Any], b: dict[str, Any]) -> Reply: _session_player(ledger, b["session_token"]) target = ledger["transactions"].get(b["target_idempotency_key"]) if target is None: raise ContractError("transaction_unknown", "no transaction with that idempotency key") # Rolling back an already-rolled-back target returns the original rollback. if "rollback" in target: return 200, {**target["rollback"], "duplicate": True} # Reverse by the amount WE stored; OneHazel's figure is a cross-check only. if b["metadata"]["amount_minor"] != target["amount_minor"]: raise ContractError("rollback_amount_mismatch", "amount differs from the stored transaction") player = _player(ledger, target["player"]) if target["kind"] == "bet": player["balance_minor"] += target["amount_minor"] else: if target["amount_minor"] > player["balance_minor"]: raise ContractError("insufficient_funds", "win already spent") player["balance_minor"] -= target["amount_minor"] reply = _wallet_reply("rolled_back", player) target["rollback"] = reply[1] return reply def _freeround(ledger: dict[str, Any], grant_id: str) -> dict[str, Any]: grant = ledger["freerounds"].get(grant_id) if grant is None: raise ContractError("freeround_unknown", "unknown free-round grant") return grant def _freeround_view(grant: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in grant.items() if k != "settle"} def freerounds_issue(ledger: dict[str, Any], b: dict[str, Any]) -> Reply: player = _player(ledger, b["player_external_id"]) _check_currency(player, b["currency"]) grant_id = _id("fr_") grant = { "freeround_external_id": grant_id, "status": "active", "player_external_id": player["external_id"], "count": b["count"], "rounds_played": 0, "value_minor": b["value_minor"], "currency": b["currency"], } if b.get("valid_until"): grant["valid_until"] = b["valid_until"] ledger["freerounds"][grant_id] = grant return 200, {"freeround_external_id": grant_id} def freerounds_cancel(ledger: dict[str, Any], b: dict[str, Any]) -> Reply: grant = _freeround(ledger, b["freeround_external_id"]) if grant["status"] != "active": raise ContractError("invalid_request", f"grant is {grant['status']}") grant["status"] = "cancelled" return 200, {**_freeround_view(grant), "duplicate": False} def freerounds_settle(ledger: dict[str, Any], b: dict[str, Any]) -> Reply: grant = _freeround(ledger, b["freeround_external_id"]) # A grant settles once. Any later settle gets the original answer and credits nothing. if "settle" in grant: return 200, {**grant["settle"], "duplicate": True} if grant["status"] != "active": raise ContractError("invalid_request", f"grant is {grant['status']}") player = _player(ledger, grant["player_external_id"]) # The lump credit happens HERE; OneHazel never sends a wallet/win for the same grant. player["balance_minor"] += b["total_win_minor"] grant["status"] = b["final_status"] grant["rounds_played"] = b["rounds_played"] grant["settle"] = {**_freeround_view(grant), "balance_minor": player["balance_minor"], "duplicate": False} return 200, grant["settle"] def freerounds_get(ledger: dict[str, Any], b: dict[str, Any]) -> Reply: return 200, _freeround_view(_freeround(ledger, b["freeround_external_id"])) HANDLERS: dict[str, Callable[[dict[str, Any], dict[str, Any]], Reply]] = { "/session/start": session_start, "/session/exchange": session_exchange, "/session/balance": session_balance, "/wallet/bet": wallet_bet, "/wallet/win": wallet_win, "/wallet/rollback": wallet_rollback, "/freerounds/issue": freerounds_issue, "/freerounds/cancel": freerounds_cancel, "/freerounds/settle": freerounds_settle, "/freerounds/get": freerounds_get, } def _fingerprint(value: Any) -> str: """Stable fingerprint of a body, so a replay with reordered keys still matches.""" return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() def handle(path: str, header: Header, raw_body: bytes) -> Reply: """The whole module: one call per request, framework-agnostic.""" try: op = find_op(path) if op is None: return 404, {"code": "invalid_request", "message": "unknown operation", "retryable": False} env = authenticate(header, raw_body) body = check_fields(parse_body(raw_body), op["fields"], op["closed"], "") with _LOCK: ledger = LEDGERS[env] if not op["idempotent"]: return HANDLERS[path](ledger, body) key = header("idempotency-key") if not key or key != body["idempotency_key"]: raise ContractError("invalid_request", "Idempotency-Key header must equal body idempotency_key") print_ = _fingerprint({"path": path, "body": body}) stored = ledger["idempotency"].get(key) if stored is not None: if stored["fingerprint"] != print_: raise ContractError("idempotency_key_conflict", "same idempotency key, different body") status, original = stored["reply"] # Return-original: the first answer, flagged as a replay. Never an error. return status, ({**original, "duplicate": True} if "duplicate" in original else original) try: reply = HANDLERS[path](ledger, body) except ContractError as e: reply = error_reply(e.code, e.message) # Retryable failures are not stored: OneHazel retries them with the same key. if reply[1].get("retryable") is not True: ledger["idempotency"][key] = {"fingerprint": print_, "reply": reply} return reply except ContractError as e: return error_reply(e.code, e.message) except Exception as e: # noqa: BLE001 print(f"wallet handler failed: {type(e).__name__}", file=sys.stderr) return error_reply("internal_error", "internal error") # ───────────────────────────── FastAPI wiring ───────────────────────────── app = FastAPI() @app.post(BASE_PATH + "/{operation:path}") async def onehazel(operation: str, request: Request) -> JSONResponse: # Read the RAW body: the signature is computed over the exact bytes OneHazel sent. raw = await request.body() status, body = handle("/" + operation, lambda name: request.headers.get(name), raw) return JSONResponse(body, status_code=status) ``` ## Before you go live - Replace the in-memory (or file) stores with your database. The idempotency store must survive restarts. - Point the `live` ledger at your real wallet; keep `test` traffic on the test ledger. - Run conformance (`connect_run_conformance`) until every scenario passes.