Skip to content
VAS HUB
Info

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",
}));