--- title: "wallet sample: TypeScript + Fastify" description: "Operator Contract v1.4.1 wallet module, TypeScript + Fastify. Generated from the contract." --- # 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=,v1=`, HMAC-SHA256 of `"." + raw body`, 300 s tolerance, constant-time compare) over the RAW body before it is parsed, then `X-OneHazel-Env`. `test` traffic goes to a separate test ledger and never touches the live one. Errors use the flat `{ code, message, retryable }` envelope; money is integer minor units. Money-moving calls are idempotent: a replay of the same `Idempotency-Key` returns the ORIGINAL response with `duplicate: true`; the same key with a different body is `409 idempotency_key_conflict`. ## Run it ```bash npm install fastify npm install --save-dev tsx @types/node ONEHAZEL_CALLBACK_KEY= ONEHAZEL_SIGNING_SECRET= npx tsx onehazel-wallet.ts # base URL to register: https:///onehazel/v1 (ONEHAZEL_BASE_PATH changes the path) ``` Environment: `ONEHAZEL_CALLBACK_KEY` and `ONEHAZEL_SIGNING_SECRET` are the values `connect_register_endpoint` showed you once. During a secret rotation set `ONEHAZEL_SIGNING_SECRET_PREVIOUS` to the old secret for the overlap hour. `ONEHAZEL_TEST_PLAYER_ID` (default `test-player-1`) is the funded player on the test ledger. ## Code `onehazel-wallet.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; 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=,v1=." + 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; launchTokens: Map; sessions: Map; transactions: Map; closedRounds: Set; freerounds: Map; idempotency: Map; } 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 = { 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(["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 = { "/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 { 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 `live` ledger at your real wallet; keep `test` traffic on the test ledger. - Run conformance (`connect_run_conformance`) until every scenario passes.