Code examples
A complete, working wallet to start from: every method, the signature check, and repeats answered with their first result.
1. Two tables
Next to your players table (with id, username, phone, balance and status):
-- Launch tokens you hand to the game (one per game opening).
CREATE TABLE game_launches (
token text PRIMARY KEY,
player_id bigint NOT NULL REFERENCES players (id),
expires_at timestamptz NOT NULL
);
-- Every money call we accepted, keyed so a repeat finds its first answer.
CREATE TABLE game_transactions (
id bigserial PRIMARY KEY,
key text NOT NULL UNIQUE, -- "<client>:<withdraw|deposit>:<action>:<provider_tx_id>"
player_id bigint NOT NULL,
amount numeric(18, 2) NOT NULL, -- negative for withdraws
old_balance numeric(18, 2) NOT NULL,
new_balance numeric(18, 2) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);2. The wallet API
Base URL https://wallet.yourcasino.com/vashub-wallet. Needs express and pg.
// A complete VAS HUB games wallet: /auth, /info, /withdraw, /deposit under one base URL.
import crypto from "node:crypto";
import express from "express";
import pg from "pg";
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const CLIENTS = { avionix: process.env.VASHUB_SECRET_AVIONIX }; // X-Client-ID -> secret
const CURRENCY = "KES";
const app = express();
// Keep the raw body: the signature covers the exact bytes we were sent.
app.use("/vashub-wallet", express.raw({ type: "*/*", limit: "64kb" }));
const reply = (res, code, message, data) =>
res.status(code).json(data === undefined ? { code, message } : { code, message, data });
function signedByVasHub(req) {
const secret = CLIENTS[req.get("X-Client-ID")];
const ts = req.get("X-Client-TS") ?? "";
const given = Buffer.from(req.get("X-Client-Signature") ?? "", "hex");
if (!secret || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const path = req.originalUrl.split("?")[0];
const expected = crypto.createHmac("sha256", secret).update(ts).update(path).update(req.body).digest();
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
const describe = (p) => ({
user_id: String(p.id), username: p.username, phone: p.phone, balance: Number(p.balance), currency: CURRENCY,
});
async function auth(res, body) {
const token = String(body.user_token ?? body.token ?? "");
const { rows } = await db.query(
`SELECT p.id, p.username, p.phone, p.balance
FROM game_launches l JOIN players p ON p.id = l.player_id
WHERE l.token = $1 AND l.expires_at > now() AND p.status = 'active'`, [token]);
return rows[0] ? reply(res, 200, "ok", describe(rows[0])) : reply(res, 401, "User token is invalid");
}
async function info(res, body) {
const { rows } = await db.query("SELECT id, username, phone, balance FROM players WHERE id = $1", [body.user_id]);
return rows[0] ? reply(res, 200, "ok", describe(rows[0])) : reply(res, 404, "Unknown player");
}
// withdraw: sign = -1, deposit: sign = +1. Idempotent by (client, direction, action, provider_tx_id).
async function move(res, client, body, sign) {
const amount = Number(body.amount);
if (!body.user_id || !Number.isFinite(amount) || amount < 0 || body.provider_tx_id == null) {
return reply(res, 400, "Bad request");
}
const direction = sign < 0 ? "withdraw" : "deposit";
const key = `${client}:${direction}:${body.action}:${body.provider_tx_id}`;
const conn = await db.connect();
try {
await conn.query("BEGIN");
// One at a time per transaction, so a retry racing the original can't move money twice.
await conn.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [key]);
let { rows: [tx] } = await conn.query(
"SELECT id, old_balance, new_balance FROM game_transactions WHERE key = $1", [key]);
if (!tx) {
const { rows: [player] } = await conn.query(
"SELECT balance, status FROM players WHERE id = $1 FOR UPDATE", [body.user_id]);
if (!player) { await conn.query("ROLLBACK"); return reply(res, 404, "Unknown player"); }
if (sign < 0 && player.status !== "active") { await conn.query("ROLLBACK"); return reply(res, 403, "Account is not active"); }
if (sign < 0 && Number(player.balance) < amount) { await conn.query("ROLLBACK"); return reply(res, 402, "Insufficient balance"); }
const delta = (sign * amount).toFixed(2); // amounts in currency units, 2 decimals
const { rows: [moved] } = await conn.query(
`UPDATE players SET balance = balance + $2::numeric WHERE id = $1
RETURNING balance - $2::numeric AS old_balance, balance AS new_balance`, [body.user_id, delta]);
({ rows: [tx] } = await conn.query(
`INSERT INTO game_transactions (key, player_id, amount, old_balance, new_balance)
VALUES ($1, $2, $3, $4, $5) RETURNING id, old_balance, new_balance`,
[key, body.user_id, delta, moved.old_balance, moved.new_balance]));
// Also record the bet itself here: open it on "bet", settle it on "win" / "rollback".
}
await conn.query("COMMIT");
// A repeat lands here too, with the first transaction's balances.
return reply(res, 200, "ok", {
operator_tx_id: String(tx.id), user_id: String(body.user_id), provider: client,
provider_tx_id: body.provider_tx_id, old_balance: Number(tx.old_balance),
new_balance: Number(tx.new_balance), currency: CURRENCY,
});
} catch (error) {
await conn.query("ROLLBACK").catch(() => {});
throw error;
} finally {
conn.release();
}
}
app.post("/vashub-wallet/:method", async (req, res) => {
if (!signedByVasHub(req)) return reply(res, 401, "Invalid Client-Signature");
let body;
try { body = JSON.parse(req.body.toString("utf8")); } catch { return reply(res, 400, "Bad request"); }
try {
switch (req.params.method) {
case "auth": return await auth(res, body);
case "info": return await info(res, body);
case "withdraw": return await move(res, req.get("X-Client-ID"), body, -1);
case "deposit": return await move(res, req.get("X-Client-ID"), body, +1);
default: return reply(res, 404, "Unknown method");
}
} catch (error) {
console.error(error);
return reply(res, 500, "Internal error"); // the game treats 5xx as "unclear" and retries deposits
}
});
app.listen(8080);3. Launching a game
import crypto from "node:crypto";
// When a signed-in player opens AvioniX: a fresh launch token, valid as long as their session.
app.get("/play/avionix", requireLogin, async (req, res) => {
const token = crypto.randomBytes(24).toString("hex");
await db.query(
"INSERT INTO game_launches (token, player_id, expires_at) VALUES ($1, $2, now() + interval '12 hours')",
[token, req.user.id],
);
const url = new URL("https://avionix-v3-web.vaslogic.com/embed"); // AvioniX's launch host
url.search = new URLSearchParams({
operator: "yourcasino", token, currency: "KES", lang: "EN",
return_url: "https://yourcasino.com/casino",
}).toString();
res.render("game", { gameUrl: url.toString() }); // <iframe src="{{gameUrl}}">
});Signature checks in other languages
import crypto from "node:crypto";
// X-Client-ID -> secret key (from your onboarding email; keep it in your secrets store).
const CLIENTS = { avionix: process.env.VASHUB_SECRET_AVIONIX };
// rawBody: the request body exactly as received (a Buffer), before any JSON parsing.
// path: the request path without the query string, e.g. "/vashub-wallet/withdraw".
export function isSignedByVasHub(headers, path, rawBody) {
const secret = CLIENTS[headers["x-client-id"]];
const ts = headers["x-client-ts"] ?? "";
const given = Buffer.from(headers["x-client-signature"] ?? "", "hex");
if (!secret || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(ts).update(path).update(rawBody).digest();
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}Testing your wallet
// Sends a signed test call to your wallet, the way our game server does.
import crypto from "node:crypto";
const BASE = "https://wallet.yourcasino.com/vashub-wallet"; // your wallet base URL
const SECRET = process.env.VASHUB_SECRET_AVIONIX;
async function call(method, body) {
const url = new URL(`${BASE}/${method}`);
const payload = JSON.stringify(body);
const ts = Math.floor(Date.now() / 1000).toString();
const signature = crypto.createHmac("sha256", SECRET).update(ts + url.pathname + payload).digest("hex");
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Client-ID": "avionix", "X-Client-TS": ts, "X-Client-Signature": signature },
body: payload,
});
return { status: res.status, body: await res.json() };
}
console.log(await call("withdraw", {
user_id: "100245", session_token: "<a launch token>", currency: "KES", amount: 10, game: "avionix",
action_id: 900000001, action: "bet", provider: "avionix", provider_tx_id: 900000001, platform: "mobile",
}));