--- title: "wallet sample: PHP (no framework)" description: "Operator Contract v1.4.1 wallet module, PHP (no framework). Generated from the contract." --- # wallet: PHP (no framework) 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 `php/plain/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 ONEHAZEL_CALLBACK_KEY= ONEHAZEL_SIGNING_SECRET= php -S 0.0.0.0:3000 onehazel-wallet.php # 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.php` ```php (string) getenv($k); $key = (string) $env('ONEHAZEL_CALLBACK_KEY'); $secret = (string) $env('ONEHAZEL_SIGNING_SECRET'); if ($secret === '' || ($requireCallbackKey && $key === '')) { // Name the variables, never print their values. throw new \RuntimeException($requireCallbackKey ? 'ONEHAZEL_CALLBACK_KEY and ONEHAZEL_SIGNING_SECRET must be set' : 'ONEHAZEL_SIGNING_SECRET must be set'); } $secrets = array_values(array_filter([$secret, (string) $env('ONEHAZEL_SIGNING_SECRET_PREVIOUS')])); return new self($contract, $key, $secrets); } /** @return array{0:int,1:array} [HTTP status, flat error envelope] */ public function errorReply(string $code, string $message): array { return [ $this->contract['errorStatus'][$code] ?? 500, ['code' => $code, 'message' => $message, 'retryable' => in_array($code, $this->contract['retryable'], true)], ]; } public function op(string $path): ?array { foreach ($this->contract['ops'] as $op) { if ($op['path'] === $path) { return $op; } } return null; } /** * `X-OneHazel-Signature: t=,v1=." + raw body)>`. * Runs over the RAW body, before it is parsed. Constant-time compare. */ public static function verifySignature(string $rawBody, ?string $header, array $secrets, int $toleranceSec, ?int $now = null): bool { if ($header === null || $header === '') { return false; } $t = null; $macs = []; foreach (explode(',', $header) as $part) { $kv = explode('=', trim($part), 2); if (count($kv) !== 2) { continue; } if ($kv[0] === 't' && ctype_digit($kv[1])) { $t = (int) $kv[1]; } if ($kv[0] === 'v1' && preg_match('/^[0-9a-f]{64}$/', $kv[1])) { $macs[] = $kv[1]; } } $now ??= time(); if ($t === null || abs($now - $t) > $toleranceSec || $macs === []) { return false; } foreach ($secrets as $secret) { $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); foreach ($macs as $mac) { if (hash_equals($expected, $mac)) { return true; } } } return false; } /** Bearer, then signature over the raw body, then X-OneHazel-Env. Returns "test" or "live". */ public function authenticate(callable $header, string $rawBody): string { $auth = (string) $header('authorization'); if (!hash_equals('Bearer ' . $this->callbackKey, $auth)) { throw new OneHazelError('unauthorized', 'missing or wrong callback key'); } if (!self::verifySignature($rawBody, $header('x-onehazel-signature'), $this->secrets, (int) $this->contract['toleranceSec'])) { throw new OneHazelError('signature_invalid', 'bad or stale X-OneHazel-Signature'); } $env = $header('x-onehazel-env'); if ($env !== 'test' && $env !== 'live') { throw new OneHazelError('invalid_request', 'X-OneHazel-Env must be test or live'); } return $env; } /** * Webhook deliveries: `X-OneHazel-Signature: sha256=`, the * OneHazel webhook engine's format. No timestamp, no bearer, no X-OneHazel-Env, so the * signature is the only proof of origin and deduplicating on deliveryId is the only replay * defence. Runs over the RAW body, before it is parsed. Constant-time compare. */ public static function verifyWebhookSignature(string $rawBody, ?string $header, array $secrets): bool { if ($header === null || !preg_match('/^sha256=([0-9a-f]{64})$/', trim($header), $m)) { return false; } $ok = false; foreach ($secrets as $secret) { // No early exit: every secret is compared. $ok = hash_equals(hash_hmac('sha256', $rawBody, $secret), $m[1]) || $ok; } return $ok; } public function authenticateWebhook(callable $header, string $rawBody): void { if (!self::verifyWebhookSignature($rawBody, $header('x-onehazel-signature'), $this->secrets)) { throw new OneHazelError('signature_invalid', 'bad X-OneHazel-Signature'); } } public static function parseBody(string $rawBody): mixed { try { return json_decode($rawBody, false, 64, JSON_THROW_ON_ERROR); } catch (\JsonException) { throw new OneHazelError('invalid_request', 'body is not valid JSON'); } } /** Checks a decoded body against the contract's field list; returns it as an array. */ public static function checkFields(mixed $value, array $fields, bool $closed, string $where): array { if (!is_object($value)) { throw new OneHazelError('invalid_request', ($where === '' ? 'body' : $where) . ' must be a JSON object'); } $obj = get_object_vars($value); if ($closed) { $known = array_column($fields, 'name'); foreach (array_keys($obj) as $key) { if (!in_array($key, $known, true)) { throw new OneHazelError('invalid_request', "unknown field {$where}{$key}"); } } } foreach ($fields as $f) { $name = $where . $f['name']; $v = $obj[$f['name']] ?? null; if ($v === null) { if ($f['required']) { throw new OneHazelError('invalid_request', "{$name} is required"); } continue; } $ok = match ($f['type']) { 'integer' => is_int($v), 'string' => is_string($v), 'boolean' => is_bool($v), 'array' => is_array($v), 'object' => is_object($v), default => true, }; if (!$ok) { throw new OneHazelError('invalid_request', "{$name} must be {$f['type']}"); } if (is_int($v)) { if (isset($f['minimum']) && $v < $f['minimum']) { throw new OneHazelError('invalid_request', "{$name} is below {$f['minimum']}"); } if (isset($f['exclusiveMinimum']) && $v <= $f['exclusiveMinimum']) { throw new OneHazelError('invalid_request', "{$name} must be greater than {$f['exclusiveMinimum']}"); } } if (is_string($v)) { $len = mb_strlen($v); if (isset($f['minLength']) && $len < $f['minLength']) { throw new OneHazelError('invalid_request', "{$name} is too short"); } if (isset($f['maxLength']) && $len > $f['maxLength']) { throw new OneHazelError('invalid_request', "{$name} is too long"); } if (isset($f['pattern']) && !preg_match('/' . str_replace('/', '\/', $f['pattern']) . '/u', $v)) { throw new OneHazelError('invalid_request', "{$name} is malformed"); } if (isset($f['enum']) && !in_array($v, $f['enum'], true)) { throw new OneHazelError('invalid_request', "{$name} is not an allowed value"); } } if (isset($f['fields'])) { self::checkFields($v, $f['fields'], false, "{$name}."); } } return json_decode(json_encode($value), true); } /** Stable fingerprint of a body, so a replay with reordered keys still matches. */ public static function fingerprint(array $value): string { $canonical = function (mixed $v) use (&$canonical): mixed { if (!is_array($v)) { return $v; } if (!array_is_list($v)) { ksort($v); } return array_map($canonical, $v); }; return hash('sha256', json_encode($canonical($value))); } } /** * State that survives between PHP requests: one JSON file, locked for the whole * request. Fine for a sample; use your database in production. */ final class OneHazelFileStore { public function __construct(private string $path) { } public static function fromEnv(string $name, ?callable $env = null): self { $env ??= static fn (string $k): string => (string) getenv($k); $path = (string) $env('ONEHAZEL_STORE_PATH'); return new self($path !== '' ? $path : sys_get_temp_dir() . "/onehazel-{$name}-store.json"); } /** Runs $fn(&$state) under an exclusive lock and saves whatever it leaves in $state. */ public function transaction(callable $fn): mixed { $fh = fopen($this->path, 'c+'); if ($fh === false || !flock($fh, LOCK_EX)) { throw new OneHazelError('unavailable', 'store is unavailable'); } try { $raw = stream_get_contents($fh); $state = $raw ? json_decode($raw, true) : null; $result = $fn($state); ftruncate($fh, 0); rewind($fh); fwrite($fh, json_encode($state)); return $result; } finally { flock($fh, LOCK_UN); fclose($fh); } } } /** * The wallet module. One ledger per X-OneHazel-Env: conformance runs always send * `test`, so they only ever touch the test ledger. Replace the `live` ledger with * your real wallet and keep every store (idempotency included) in your database. */ final class OneHazelWalletModule { public const CONTRACT_JSON = <<<'JSON' { "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 } ] } JSON; // 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. private const KNOWN_CURRENCIES = ["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"]; public function __construct(private OneHazelProtocol $protocol, private OneHazelFileStore $store, private array $testPlayer) { } public static function contract(): array { return json_decode(self::CONTRACT_JSON, true); } /** @param callable|null $env reads a setting by name; defaults to getenv() */ public static function fromEnv(?callable $env = null): self { $env ??= static fn (string $k): string => (string) getenv($k); $testPlayer = [ 'external_id' => $env('ONEHAZEL_TEST_PLAYER_ID') ?: 'test-player-1', 'currency' => $env('ONEHAZEL_TEST_PLAYER_CURRENCY') ?: 'EUR', 'balance_minor' => (int) ($env('ONEHAZEL_TEST_PLAYER_BALANCE') ?: 100000), 'country' => 'MT', 'status' => 'active', 'name' => 'Test Player', ]; return new self(OneHazelProtocol::fromEnv(self::contract(), $env), OneHazelFileStore::fromEnv('wallet', $env), $testPlayer); } /** @return array{0:int,1:array} [HTTP status, JSON body] */ public function handle(string $path, callable $header, string $rawBody): array { try { $op = $this->protocol->op($path); if ($op === null) { return [404, ['code' => 'invalid_request', 'message' => 'unknown operation', 'retryable' => false]]; } $env = $this->protocol->authenticate($header, $rawBody); $body = OneHazelProtocol::checkFields(OneHazelProtocol::parseBody($rawBody), $op['fields'], $op['closed'], ''); $key = null; if ($op['idempotent']) { $key = $header('idempotency-key'); if ($key === null || $key !== $body['idempotency_key']) { throw new OneHazelError('invalid_request', 'Idempotency-Key header must equal body idempotency_key'); } } return $this->store->transaction(function (?array &$state) use ($env, $path, $body, $key): array { $state ??= ['test' => self::newLedger([$this->testPlayer]), 'live' => self::newLedger([])]; $ledger = &$state[$env]; if ($key === null) { return $this->dispatch($ledger, $path, $body); } $print = OneHazelProtocol::fingerprint(['path' => $path, 'body' => $body]); $stored = $ledger['idempotency'][$key] ?? null; if ($stored !== null) { if ($stored['fingerprint'] !== $print) { throw new OneHazelError('idempotency_key_conflict', 'same idempotency key, different body'); } // Return-original: the first answer, flagged as a replay. Never an error. $original = $stored['body']; if (array_key_exists('duplicate', $original)) { $original['duplicate'] = true; } return [$stored['status'], $original]; } try { $reply = $this->dispatch($ledger, $path, $body); } catch (OneHazelError $e) { $reply = $this->protocol->errorReply($e->errorCode, $e->getMessage()); } // Retryable failures are not stored: OneHazel retries them with the same key. if (($reply[1]['retryable'] ?? false) !== true) { $ledger['idempotency'][$key] = ['fingerprint' => $print, 'status' => $reply[0], 'body' => $reply[1]]; } return $reply; }); } catch (OneHazelError $e) { return $this->protocol->errorReply($e->errorCode, $e->getMessage()); } catch (\Throwable $e) { error_log('wallet handler failed: ' . get_class($e)); return $this->protocol->errorReply('internal_error', 'internal error'); } } private static function newLedger(array $players): array { $byId = []; foreach ($players as $p) { $byId[$p['external_id']] = $p; } return ['players' => $byId, 'launch_tokens' => [], 'sessions' => [], 'transactions' => [], 'closed_rounds' => [], 'freerounds' => [], 'idempotency' => []]; } private function dispatch(array &$ledger, string $path, array $b): array { return match ($path) { '/session/start' => $this->sessionStart($ledger, $b), '/session/exchange' => $this->sessionExchange($ledger, $b), '/session/balance' => $this->sessionBalance($ledger, $b), '/wallet/bet' => $this->bet($ledger, $b), '/wallet/win' => $this->win($ledger, $b), '/wallet/rollback' => $this->rollback($ledger, $b), '/freerounds/issue' => $this->freeroundsIssue($ledger, $b), '/freerounds/cancel' => $this->freeroundsCancel($ledger, $b), '/freerounds/settle' => $this->freeroundsSettle($ledger, $b), '/freerounds/get' => [200, self::freeroundView($this->freeround($ledger, $b['freeround_external_id']))], default => throw new OneHazelError('invalid_request', 'unknown operation'), }; } private static function id(string $prefix): string { return $prefix . bin2hex(random_bytes(16)); } private function &player(array &$ledger, string $id): array { if (!isset($ledger['players'][$id])) { throw new OneHazelError('player_unknown', 'unknown player'); } return $ledger['players'][$id]; } private function &sessionPlayer(array &$ledger, string $token): array { $id = $ledger['sessions'][$token] ?? null; if ($id === null) { throw new OneHazelError('session_unknown', 'unknown session'); } return $this->player($ledger, $id); } private function checkCurrency(array $player, string $currency): void { if (!in_array($currency, self::KNOWN_CURRENCIES, true)) { throw new OneHazelError('invalid_request', "currency {$currency} is not in the Operator Contract currency table"); } if ($currency !== $player['currency']) { throw new OneHazelError('currency_mismatch', "currency does not match the player's wallet"); } } private static function walletReply(string $status, array $player): array { return [200, [ 'status' => $status, 'player_external_id' => $player['external_id'], 'currency' => $player['currency'], 'balance_minor' => $player['balance_minor'], 'transaction_external_id' => self::id('tx_'), 'duplicate' => false, ]]; } private function sessionStart(array &$ledger, array $b): array { $player = $this->player($ledger, $b['player_external_id']); if ($player['status'] !== 'active') { throw new OneHazelError('player_blocked', 'player cannot play'); } $this->checkCurrency($player, $b['currency']); $token = self::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']], ]]; } private function sessionExchange(array &$ledger, array $b): array { $launch = $ledger['launch_tokens'][$b['launch_token']] ?? null; if ($launch === null) { throw new OneHazelError('launch_token_unknown', 'unknown launch token'); } // A replay with the same idempotency key never reaches here: it gets the original session. if ($launch['consumed']) { throw new OneHazelError('launch_token_consumed', 'launch token already exchanged'); } $ledger['launch_tokens'][$b['launch_token']]['consumed'] = true; $player = $this->player($ledger, $launch['player']); $token = self::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['name'] ?? null, 'country' => $player['country'], ], ]]; } private function sessionBalance(array &$ledger, array $b): array { $player = $this->sessionPlayer($ledger, $b['session_token']); return [200, ['player_external_id' => $player['external_id'], 'currency' => $player['currency'], 'balance_minor' => $player['balance_minor']]]; } private function bet(array &$ledger, array $b): array { $player = &$this->sessionPlayer($ledger, $b['session_token']); $this->checkCurrency($player, $b['currency']); if ($player['status'] !== 'active') { throw new OneHazelError('player_blocked', 'player cannot play'); } if (isset($ledger['closed_rounds'][$b['round_external_id']])) { throw new OneHazelError('round_closed', 'round is closed'); } if ($b['amount_minor'] > $player['balance_minor']) { throw new OneHazelError('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 self::walletReply('accepted', $player); } private function win(array &$ledger, array $b): array { $player = &$this->sessionPlayer($ledger, $b['session_token']); $this->checkCurrency($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['round_closed'] ?? false) === true) { $ledger['closed_rounds'][$b['round_external_id']] = true; } return self::walletReply('accepted', $player); } private function rollback(array &$ledger, array $b): array { $this->sessionPlayer($ledger, $b['session_token']); $target = &$ledger['transactions'][$b['target_idempotency_key']]; if ($target === null) { unset($ledger['transactions'][$b['target_idempotency_key']]); throw new OneHazelError('transaction_unknown', 'no transaction with that idempotency key'); } // Rolling back an already-rolled-back target returns the original rollback. if (isset($target['rollback'])) { return [200, array_merge($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']) { throw new OneHazelError('rollback_amount_mismatch', 'amount differs from the stored transaction'); } $player = &$this->player($ledger, $target['player']); if ($target['kind'] === 'bet') { $player['balance_minor'] += $target['amount_minor']; } else { if ($target['amount_minor'] > $player['balance_minor']) { throw new OneHazelError('insufficient_funds', 'win already spent'); } $player['balance_minor'] -= $target['amount_minor']; } $reply = self::walletReply('rolled_back', $player); $target['rollback'] = $reply[1]; return $reply; } private function &freeround(array &$ledger, string $id): array { if (!isset($ledger['freerounds'][$id])) { throw new OneHazelError('freeround_unknown', 'unknown free-round grant'); } return $ledger['freerounds'][$id]; } private static function freeroundView(array $grant): array { unset($grant['settle']); return $grant; } private function freeroundsIssue(array &$ledger, array $b): array { $player = $this->player($ledger, $b['player_external_id']); $this->checkCurrency($player, $b['currency']); $id = self::id('fr_'); $ledger['freerounds'][$id] = array_filter([ 'freeround_external_id' => $id, 'status' => 'active', 'player_external_id' => $player['external_id'], 'count' => $b['count'], 'rounds_played' => 0, 'value_minor' => $b['value_minor'], 'currency' => $b['currency'], 'valid_until' => $b['valid_until'] ?? null, ], fn ($v) => $v !== null); return [200, ['freeround_external_id' => $id]]; } private function freeroundsCancel(array &$ledger, array $b): array { $grant = &$this->freeround($ledger, $b['freeround_external_id']); if ($grant['status'] !== 'active') { throw new OneHazelError('invalid_request', "grant is {$grant['status']}"); } $grant['status'] = 'cancelled'; return [200, array_merge(self::freeroundView($grant), ['duplicate' => false])]; } private function freeroundsSettle(array &$ledger, array $b): array { $grant = &$this->freeround($ledger, $b['freeround_external_id']); // A grant settles once. Any later settle gets the original answer and credits nothing. if (isset($grant['settle'])) { return [200, array_merge($grant['settle'], ['duplicate' => true])]; } if ($grant['status'] !== 'active') { throw new OneHazelError('invalid_request', "grant is {$grant['status']}"); } $player = &$this->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'] = array_merge(self::freeroundView($grant), ['balance_minor' => $player['balance_minor'], 'duplicate' => false]); return [200, $grant['settle']]; } } // ───────────────────────────── entry point (php -S or PHP-FPM) ───────────────────────────── function onehazel_request_headers(): array { if (function_exists('getallheaders')) { return array_change_key_case(getallheaders(), CASE_LOWER); } $out = []; foreach ($_SERVER as $k => $v) { if (str_starts_with($k, 'HTTP_')) { $out[strtolower(str_replace('_', '-', substr($k, 5)))] = $v; } } return $out; } function onehazel_send(int $status, array $body): void { http_response_code($status); header('Content-Type: application/json'); echo json_encode($body, JSON_UNESCAPED_SLASHES); } $basePath = rtrim((string) (getenv('ONEHAZEL_BASE_PATH') ?: '/onehazel/v1'), '/'); $uri = (string) parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !str_starts_with($uri, $basePath . '/')) { onehazel_send(404, ['code' => 'invalid_request', 'message' => 'unknown operation', 'retryable' => false]); return; } try { $module = OneHazelWalletModule::fromEnv(); } catch (\RuntimeException $e) { error_log($e->getMessage()); onehazel_send(500, ['code' => 'internal_error', 'message' => 'module is not configured', 'retryable' => true]); return; } $headers = onehazel_request_headers(); // Read the RAW body: the signature is computed over the exact bytes OneHazel sent. [$status, $body] = $module->handle(substr($uri, strlen($basePath)), fn (string $name): ?string => $headers[$name] ?? null, (string) file_get_contents('php://input')); onehazel_send($status, $body); ``` ## 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.