Appearance
wallet: TypeScript + Fastify
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 typescript/fastify/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
npm install fastify
npm install --save-dev tsx @types/node
ONEHAZEL_CALLBACK_KEY=<callback key> ONEHAZEL_SIGNING_SECRET=<signing secret> npx tsx onehazel-wallet.ts
# 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.ts
ts
import Fastify from "fastify";
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
// Generated from the OneHazel Operator Contract v1.4.1: operations, request fields,
// error codes with their HTTP status, and the signature tolerance. Do not hand-edit; regenerate.
const CONTRACT = {
"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
}
]
} as const;
// ───────────────────────────── configuration ─────────────────────────────
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
// Never print the value of a secret, only its name.
console.error(`${name} is not set`);
process.exit(1);
}
return value;
}
// During a rotation OneHazel signs with the new secret; the old one stays valid for 1 hour.
const SIGNING_SECRETS = [requiredEnv("ONEHAZEL_SIGNING_SECRET"), process.env.ONEHAZEL_SIGNING_SECRET_PREVIOUS]
.filter((s): s is string => Boolean(s));
const PORT = Number(process.env.PORT ?? "3000");
const BASE_PATH = (process.env.ONEHAZEL_BASE_PATH ?? "/onehazel/v1").replace(/\/+$/, "");
// ───────────────────────────── errors ─────────────────────────────
type ErrorCode = keyof typeof CONTRACT.errorStatus;
interface Reply {
status: number;
body: unknown;
}
/** Thrown anywhere in a handler; becomes the flat `{ code, message, retryable }` envelope. */
class ContractError extends Error {
constructor(public readonly code: ErrorCode, message: string) {
super(message);
}
}
function errorReply(code: ErrorCode, message: string): Reply {
return {
status: CONTRACT.errorStatus[code],
body: { code, message, retryable: (CONTRACT.retryable as readonly string[]).includes(code) },
};
}
// ───────────────────────────── request authentication ─────────────────────────────
type HeaderGetter = (name: string) => string | undefined;
function constantTimeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
// Compare against itself on a length mismatch so the timing does not reveal the length.
return ab.length === bb.length ? timingSafeEqual(ab, bb) : (timingSafeEqual(ab, ab), false);
}
// ───────────────────────────── request validation ─────────────────────────────
type Body = Record<string, unknown>;
interface Field {
readonly name: string;
readonly type: string;
readonly required: boolean;
readonly minimum?: number;
readonly exclusiveMinimum?: number;
readonly minLength?: number;
readonly maxLength?: number;
readonly pattern?: string;
readonly enum?: readonly string[];
readonly fields?: readonly Field[];
}
function checkFields(value: unknown, fields: readonly Field[], closed: boolean, where: string): Body {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new ContractError("invalid_request", `${where || "body"} must be a JSON object`);
}
const obj = value as Body;
if (closed) {
for (const key of Object.keys(obj)) {
if (!fields.some((f) => f.name === key)) throw new ContractError("invalid_request", `unknown field ${where}${key}`);
}
}
for (const f of fields) {
const v = obj[f.name];
const name = `${where}${f.name}`;
if (v === undefined || v === null) {
if (f.required) throw new ContractError("invalid_request", `${name} is required`);
continue;
}
const ok =
f.type === "integer" ? Number.isSafeInteger(v)
: f.type === "array" ? Array.isArray(v)
: f.type === "object" ? typeof v === "object" && !Array.isArray(v)
: typeof v === f.type;
if (!ok) throw new ContractError("invalid_request", `${name} must be ${f.type}`);
if (typeof v === "number") {
if (f.minimum !== undefined && v < f.minimum) throw new ContractError("invalid_request", `${name} is below ${f.minimum}`);
if (f.exclusiveMinimum !== undefined && v <= f.exclusiveMinimum) {
throw new ContractError("invalid_request", `${name} must be greater than ${f.exclusiveMinimum}`);
}
}
if (typeof v === "string") {
if (f.minLength !== undefined && v.length < f.minLength) throw new ContractError("invalid_request", `${name} is too short`);
if (f.maxLength !== undefined && v.length > f.maxLength) throw new ContractError("invalid_request", `${name} is too long`);
if (f.pattern !== undefined && !new RegExp(f.pattern).test(v)) throw new ContractError("invalid_request", `${name} is malformed`);
if (f.enum !== undefined && !f.enum.includes(v)) throw new ContractError("invalid_request", `${name} is not an allowed value`);
}
if (f.fields) checkFields(v, f.fields, false, `${name}.`);
}
return obj;
}
function parseBody(rawBody: Buffer): unknown {
try {
return JSON.parse(rawBody.toString("utf8"));
} catch {
throw new ContractError("invalid_request", "body is not valid JSON");
}
}
// ───────────────────────────── OneHazel-to-operator call authentication ─────────────────────────────
const CALLBACK_KEY = requiredEnv("ONEHAZEL_CALLBACK_KEY");
const TEST_PLAYER_ID = process.env.ONEHAZEL_TEST_PLAYER_ID ?? "test-player-1";
const TEST_PLAYER_BALANCE = Number(process.env.ONEHAZEL_TEST_PLAYER_BALANCE ?? "100000");
const TEST_PLAYER_CURRENCY = process.env.ONEHAZEL_TEST_PLAYER_CURRENCY ?? "EUR";
function checkBearer(authorization: string | undefined): void {
if (!authorization || !constantTimeEqual(authorization, `Bearer ${CALLBACK_KEY}`)) {
throw new ContractError("unauthorized", "missing or wrong callback key");
}
}
/**
* `X-OneHazel-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>." + raw body)>`.
* Runs over the RAW bytes, before the body is parsed.
*/
function verifySignature(rawBody: Buffer, header: string | undefined, secrets: string[], nowSec: number): boolean {
if (!header) return false;
let t = NaN;
const macs: string[] = [];
for (const part of header.split(",")) {
const [k, v] = part.trim().split("=", 2);
if (k === "t") t = /^\d+$/.test(v ?? "") ? Number(v) : NaN;
if (k === "v1" && /^[0-9a-f]{64}$/.test(v ?? "")) macs.push(v);
}
if (!Number.isFinite(t) || Math.abs(nowSec - t) > CONTRACT.toleranceSec || macs.length === 0) return false;
for (const secret of secrets) {
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
if (macs.some((mac) => constantTimeEqual(mac, expected))) return true;
}
return false;
}
type Env = "test" | "live";
/** Bearer, then signature over the raw body, then the environment header. */
function authenticate(header: HeaderGetter, rawBody: Buffer): Env {
checkBearer(header("authorization"));
if (!verifySignature(rawBody, header("x-onehazel-signature"), SIGNING_SECRETS, Math.floor(Date.now() / 1000))) {
throw new ContractError("signature_invalid", "bad or stale X-OneHazel-Signature");
}
const env = header("x-onehazel-env");
if (env !== "test" && env !== "live") throw new ContractError("invalid_request", "X-OneHazel-Env must be test or live");
return env;
}
// ───────────────────────────── ledgers ─────────────────────────────
//
// One ledger per X-OneHazel-Env. Conformance runs always send `test`, so they only
// ever touch the test ledger. This sample keeps both in memory; replace `live`
// with your real wallet and keep every store (idempotency included) in your database.
interface Player {
external_id: string;
currency: string;
balance_minor: number;
country: string;
status: "active" | "suspended" | "self_excluded" | "closed";
name?: string;
}
interface WalletResponse {
status: "accepted" | "rolled_back";
player_external_id: string;
currency: string;
balance_minor: number;
transaction_external_id: string;
duplicate: boolean;
}
interface Transaction {
kind: "bet" | "win";
player: string;
amount_minor: number;
rollback?: WalletResponse;
}
interface Freeround {
freeround_external_id: string;
status: "active" | "consumed" | "expired" | "cancelled";
player_external_id: string;
count: number;
rounds_played: number;
value_minor: number;
currency: string;
valid_until?: string;
settle?: Body;
}
interface StoredReply {
fingerprint: string;
reply: Reply;
}
interface Ledger {
players: Map<string, Player>;
launchTokens: Map<string, { player: string; consumed: boolean }>;
sessions: Map<string, string>;
transactions: Map<string, Transaction>;
closedRounds: Set<string>;
freerounds: Map<string, Freeround>;
idempotency: Map<string, StoredReply>;
}
function newLedger(players: Player[]): Ledger {
return {
players: new Map(players.map((p) => [p.external_id, p])),
launchTokens: new Map(),
sessions: new Map(),
transactions: new Map(),
closedRounds: new Set(),
freerounds: new Map(),
idempotency: new Map(),
};
}
const LEDGERS: Record<Env, Ledger> = {
test: newLedger([
{ external_id: TEST_PLAYER_ID, currency: TEST_PLAYER_CURRENCY, balance_minor: TEST_PLAYER_BALANCE, country: "MT", status: "active", name: "Test Player" },
]),
// Replace with your real wallet. Never seed real-money players from test data.
live: newLedger([]),
};
// 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.
const KNOWN_CURRENCIES = new Set<string>(["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"]);
function checkCurrency(player: Player, currency: string): void {
if (!KNOWN_CURRENCIES.has(currency)) throw new ContractError("invalid_request", `currency ${currency} is not in the Operator Contract currency table`);
if (currency !== player.currency) throw new ContractError("currency_mismatch", "currency does not match the player's wallet");
}
function playerOf(ledger: Ledger, id: string): Player {
const player = ledger.players.get(id);
if (!player) throw new ContractError("player_unknown", "unknown player");
return player;
}
function sessionPlayer(ledger: Ledger, token: string): Player {
const id = ledger.sessions.get(token);
if (!id) throw new ContractError("session_unknown", "unknown session");
return playerOf(ledger, id);
}
function walletReply(status: WalletResponse["status"], player: Player): Reply {
const body: WalletResponse = {
status,
player_external_id: player.external_id,
currency: player.currency,
balance_minor: player.balance_minor,
transaction_external_id: `tx_${randomUUID()}`,
duplicate: false,
};
return { status: 200, body };
}
// ───────────────────────────── operations ─────────────────────────────
type Handler = (ledger: Ledger, body: Body) => Reply;
const HANDLERS: Record<string, Handler> = {
"/session/start": (ledger, b) => {
const player = playerOf(ledger, b.player_external_id as string);
if (player.status !== "active") throw new ContractError("player_blocked", "player cannot play");
checkCurrency(player, b.currency as string);
const token = `lt_${randomUUID()}`;
ledger.launchTokens.set(token, { player: player.external_id, consumed: false });
return {
status: 200,
body: { launch_token: { token }, player: { external_id: player.external_id, currency: player.currency, balance_minor: player.balance_minor } },
};
},
"/session/exchange": (ledger, b) => {
const launch = ledger.launchTokens.get(b.launch_token as string);
if (!launch) throw new ContractError("launch_token_unknown", "unknown launch token");
// A replay with the same idempotency key never reaches here: it gets the original session.
if (launch.consumed) throw new ContractError("launch_token_consumed", "launch token already exchanged");
launch.consumed = true;
const player = playerOf(ledger, launch.player);
const token = `st_${randomUUID()}`;
ledger.sessions.set(token, player.external_id);
return {
status: 200,
body: {
session: { token },
player: { external_id: player.external_id, currency: player.currency, balance_minor: player.balance_minor, name: player.name, country: player.country },
},
};
},
"/session/balance": (ledger, b) => {
const player = sessionPlayer(ledger, b.session_token as string);
return { status: 200, body: { player_external_id: player.external_id, currency: player.currency, balance_minor: player.balance_minor } };
},
"/wallet/bet": (ledger, b) => {
const player = sessionPlayer(ledger, b.session_token as string);
checkCurrency(player, b.currency as string);
if (player.status !== "active") throw new ContractError("player_blocked", "player cannot play");
if (ledger.closedRounds.has(b.round_external_id as string)) throw new ContractError("round_closed", "round is closed");
const amount = b.amount_minor as number;
if (amount > player.balance_minor) throw new ContractError("insufficient_funds", "balance too low");
player.balance_minor -= amount;
ledger.transactions.set(b.idempotency_key as string, { kind: "bet", player: player.external_id, amount_minor: amount });
return walletReply("accepted", player);
},
"/wallet/win": (ledger, b) => {
const player = sessionPlayer(ledger, b.session_token as string);
checkCurrency(player, b.currency as string);
const amount = b.amount_minor as number;
player.balance_minor += amount;
ledger.transactions.set(b.idempotency_key as string, { kind: "win", player: player.external_id, amount_minor: amount });
if (b.round_closed === true) ledger.closedRounds.add(b.round_external_id as string);
return walletReply("accepted", player);
},
"/wallet/rollback": (ledger, b) => {
sessionPlayer(ledger, b.session_token as string);
const target = ledger.transactions.get(b.target_idempotency_key as string);
if (!target) throw new ContractError("transaction_unknown", "no transaction with that idempotency key");
// Rolling back an already-rolled-back target returns the original rollback.
if (target.rollback) return { status: 200, body: { ...target.rollback, duplicate: true } };
// Reverse by the amount WE stored; OneHazel's figure is a cross-check only.
const claimed = (b.metadata as Body).amount_minor as number;
if (claimed !== target.amount_minor) throw new ContractError("rollback_amount_mismatch", "amount differs from the stored transaction");
const player = playerOf(ledger, target.player);
if (target.kind === "bet") {
player.balance_minor += target.amount_minor;
} else {
if (target.amount_minor > player.balance_minor) throw new ContractError("insufficient_funds", "win already spent");
player.balance_minor -= target.amount_minor;
}
const reply = walletReply("rolled_back", player);
target.rollback = reply.body as WalletResponse;
return reply;
},
"/freerounds/issue": (ledger, b) => {
const player = playerOf(ledger, b.player_external_id as string);
checkCurrency(player, b.currency as string);
const id = `fr_${randomUUID()}`;
ledger.freerounds.set(id, {
freeround_external_id: id,
status: "active",
player_external_id: player.external_id,
count: b.count as number,
rounds_played: 0,
value_minor: b.value_minor as number,
currency: b.currency as string,
valid_until: b.valid_until as string | undefined,
});
return { status: 200, body: { freeround_external_id: id } };
},
"/freerounds/cancel": (ledger, b) => {
const grant = freeroundOf(ledger, b.freeround_external_id as string);
if (grant.status !== "active") throw new ContractError("invalid_request", `grant is ${grant.status}`);
grant.status = "cancelled";
return { status: 200, body: { ...freeroundView(grant), duplicate: false } };
},
"/freerounds/settle": (ledger, b) => {
const grant = freeroundOf(ledger, b.freeround_external_id as string);
// A grant settles once. Any later settle gets the original answer and credits nothing.
if (grant.settle) return { status: 200, body: { ...grant.settle, duplicate: true } };
if (grant.status !== "active") throw new ContractError("invalid_request", `grant is ${grant.status}`);
const player = playerOf(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 as number;
grant.status = b.final_status as Freeround["status"];
grant.rounds_played = b.rounds_played as number;
grant.settle = { ...freeroundView(grant), balance_minor: player.balance_minor, duplicate: false };
return { status: 200, body: grant.settle };
},
"/freerounds/get": (ledger, b) => {
return { status: 200, body: freeroundView(freeroundOf(ledger, b.freeround_external_id as string)) };
},
};
function freeroundOf(ledger: Ledger, id: string): Freeround {
const grant = ledger.freerounds.get(id);
if (!grant) throw new ContractError("freeround_unknown", "unknown free-round grant");
return grant;
}
function freeroundView(g: Freeround): Body {
const { settle: _settle, ...view } = g;
return view;
}
/** Stable fingerprint of a parsed body, so a replay with reordered keys still matches. */
function fingerprint(value: unknown): string {
const canonical = (v: unknown): unknown =>
Array.isArray(v) ? v.map(canonical)
: v && typeof v === "object" ? Object.fromEntries(Object.keys(v as Body).sort().map((k) => [k, canonical((v as Body)[k])]))
: v;
return createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex");
}
/** The whole module: one call per request, framework-agnostic. */
async function handle(path: string, header: HeaderGetter, rawBody: Buffer): Promise<Reply> {
try {
const op = CONTRACT.ops.find((o) => o.path === path);
if (!op) return { status: 404, body: { code: "invalid_request", message: "unknown operation", retryable: false } };
const env = authenticate(header, rawBody);
const body = checkFields(parseBody(rawBody), op.fields, op.closed, "");
const ledger = LEDGERS[env];
if (!op.idempotent) return HANDLERS[path](ledger, body);
const key = header("idempotency-key");
if (!key || key !== body.idempotency_key) {
throw new ContractError("invalid_request", "Idempotency-Key header must equal body idempotency_key");
}
const print = fingerprint({ path, body });
const stored = ledger.idempotency.get(key);
if (stored) {
if (stored.fingerprint !== print) throw new ContractError("idempotency_key_conflict", "same idempotency key, different body");
const original = stored.reply.body as Body;
// Return-original: the first answer, flagged as a replay. Never an error.
return { status: stored.reply.status, body: "duplicate" in original ? { ...original, duplicate: true } : original };
}
let reply: Reply;
try {
reply = HANDLERS[path](ledger, body);
} catch (e) {
if (!(e instanceof ContractError)) throw e;
reply = errorReply(e.code, e.message);
}
// Retryable failures are not stored: OneHazel retries them with the same key.
const retryable = (reply.body as { retryable?: boolean }).retryable === true;
if (!retryable) ledger.idempotency.set(key, { fingerprint: print, reply });
return reply;
} catch (e) {
if (e instanceof ContractError) return errorReply(e.code, e.message);
console.error("wallet handler failed", e instanceof Error ? e.message : "unknown error");
return errorReply("internal_error", "internal error");
}
}
// ───────────────────────────── Fastify wiring ─────────────────────────────
const app = Fastify();
// Keep the body RAW: the signature is computed over the exact bytes OneHazel sent.
app.removeAllContentTypeParsers();
app.addContentTypeParser("*", { parseAs: "buffer" }, (_req, body, done) => done(null, body));
app.post(`${BASE_PATH}/*`, async (req, reply) => {
const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
const header: HeaderGetter = (name) => {
const v = req.headers[name];
return Array.isArray(v) ? v[0] : v;
};
const path = req.url.split("?")[0].slice(BASE_PATH.length);
const result = await handle(path, header, raw);
return reply.status(result.status).send(result.body);
});
app.listen({ port: PORT, host: "0.0.0.0" }).then(() => console.log(`OneHazel module listening on :${PORT}${BASE_PATH}`));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.