Appearance
wallet: PHP + Laravel
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/laravel/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=<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
php artisan install:api # Laravel 11+, only if routes/api.php does not exist yet
# add ONEHAZEL_CALLBACK_KEY and ONEHAZEL_SIGNING_SECRET to .env, then:
php artisan serve --port=3000
# base URL to register: https://<your host>/api/onehazel/walletEnvironment: 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
app/OneHazel/common.php
php
<?php
declare(strict_types=1);
// Shared by every OneHazel module; identical in each module's sample.
/**
* 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);
}
}
}app/OneHazel/wallet.php
php
<?php
declare(strict_types=1);
// OneHazel wallet module, Operator Contract v1.4.1. Generated; regenerate rather than edit.
/**
* 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']];
}
}app/Http/Controllers/OneHazelWalletController.php
php
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
require_once app_path('OneHazel/common.php');
require_once app_path('OneHazel/wallet.php');
final class OneHazelWalletController extends Controller
{
public function __invoke(Request $request, string $operation): JsonResponse
{
// Reads ONEHAZEL_* from .env. If you cache config, move them into config/services.php.
$module = \OneHazelWalletModule::fromEnv(fn (string $key): string => (string) env($key, ''));
// getContent() is the RAW body: the signature is computed over the exact bytes OneHazel sent.
[$status, $body] = $module->handle('/' . $operation, fn (string $name): ?string => $request->header($name), $request->getContent());
return response()->json($body, $status, [], JSON_UNESCAPED_SLASHES);
}
}routes/api.php (add)
php
<?php
use App\Http\Controllers\OneHazelWalletController;
use Illuminate\Support\Facades\Route;
// Register https://<your host>/api/onehazel/wallet as this module's base URL.
Route::post('onehazel/wallet/{operation}', OneHazelWalletController::class)->where('operation', '.*');Before you go live
- Replace the in-memory (or file) stores with your database. The idempotency store must survive restarts.
- Point the
liveledger at your real wallet; keeptesttraffic on the test ledger. - Run conformance (
connect_run_conformance) until every scenario passes.