Skip to content
For LLMsView as Markdown·

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 ​

OperationPurposeIdempotency key
POST /session/startMint a single-use launch token for a player and game.yes
POST /session/exchangeExchange a launch token for a wallet-capable session token.yes
POST /session/balanceRead the live balance for a session.no
POST /wallet/betDebit a real-money stake.yes
POST /wallet/winCredit a win.yes
POST /wallet/rollbackReverse a prior bet or win.yes
POST /freerounds/issueRecord a free-round grant (system of record stays with the operator).yes
POST /freerounds/cancelCancel an active grant. OneHazel also calls this to compensate a failed studio-side issue.yes
POST /freerounds/settleClose a grant and credit its lump-sum winnings.yes
POST /freerounds/getRead a grant's current state.no

Every request is checked in this order: the Authorization: Bearer callback key, then X-OneHazel-Signature (t=<unix seconds>,v1=<hex>, HMAC-SHA256 of "<t>." + 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=<callback key> ONEHAZEL_SIGNING_SECRET=<signing secret> php -S 0.0.0.0:3000 onehazel-wallet.php
# base URL to register: https://<your host>/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
<?php

declare(strict_types=1);

// OneHazel wallet module, Operator Contract v1.4.1. Generated; regenerate rather than edit.

/**
 * Shared Operator Contract plumbing: errors, request authentication, validation
 * and a small locked JSON-file store. Identical for every module; the module's
 * own contract data is passed in. PHP 8.1+.
 */

final class OneHazelError extends \Exception
{
    public function __construct(public readonly string $errorCode, string $message)
    {
        parent::__construct($message);
    }
}

final class OneHazelProtocol
{
    /** @param string[] $secrets current signing secret first, then the previous one during a rotation */
    public function __construct(private array $contract, private string $callbackKey, private array $secrets)
    {
    }

    /** Webhook receivers pass $requireCallbackKey = false: deliveries carry no bearer. */
    public static function fromEnv(array $contract, ?callable $env = null, bool $requireCallbackKey = true): self
    {
        $env ??= static fn (string $k): string => (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=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>." + 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=<hex HMAC-SHA256(whsec, raw body)>`, 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.