SDK wallet kit

The wallet kit of the SDK serves the five wallet operations: it verifies every signed call, checks its body and answers a repeated transaction with its first answer.

Create the kit

createWalletKit takes one typed handler per operation, a replay store and the secret of each API key you accept. The kit calls a handler only for a call that is signed, fresh and valid against the operation’s schema; the handler answers with the balance after the operation or with a canonical error code. The module below is a complete wallet in memory: every balance is in minor units, a bet refuses a stake the balance does not cover, a rollback reverses its bet or win once and only on the balance that the bet or win moved, a late bet or win after its rollback is refused, and a balance never leaves the range that a JSON number carries exactly.

wallet.ts

import type { CurrencyCode } from '@asteriks-aggregator/sdk/api'
import {
  createWalletKit,
  MemoryReplayStore,
  type WalletAnswer,
  type WalletHandlers,
  type WalletKit,
  type WalletWriteScope,
} from '@asteriks-aggregator/sdk/wallet'

/** Balances in minor units by player and currency; a real wallet keeps them in its own database. */
const balances = new Map<string, number>([['player-1001 EUR', 10_000]])
/** The balance each bet and win moved and by how much, by transaction_id, so a rollback can reverse it. */
const moves = new Map<string, Moved>()
/** The first answer of each reversed transaction and the balance it moved: one reversal per bet or win. */
const reversals = new Map<string, Reversal>()

const keyOf = (player: string, currency: CurrencyCode) => `${player} ${currency}`
type Owner = { readonly player_id: string; readonly currency: CurrencyCode }
type Move = Owner & { readonly transaction_id: string }
type Moved = Owner & { readonly amount: number }
type Reversal = Owner & { readonly answer: WalletAnswer }
const UNKNOWN_PLAYER: WalletAnswer = { error: { code: 'PLAYER_NOT_FOUND', message: 'Unknown player.' } }
const ROLLED_BACK_FIRST: WalletAnswer = {
  error: { code: 'DUPLICATE_TRANSACTION', message: 'The transaction was rolled back before it arrived.' },
}
/** The largest balance in minor units that a JSON number carries exactly; the API refuses a larger one. */
const BALANCE_LIMIT = BigInt(Number.MAX_SAFE_INTEGER)

/**
 * The writing step of a handler: records in memory have no rollback, so it asks `scope.held()` in the same
 * synchronous step as its writes, and writes only while the kit's claim still holds the transaction_id.
 */
function written(scope: WalletWriteScope, write: () => WalletAnswer): WalletAnswer {
  if (!scope.held()) throw new Error('The claim on this transaction_id was lost; its first answer stands.')
  return write()
}

/**
 * `balance + amount` in exact integers. A result outside the balance range throws before any write, so the kit answers
 * INTERNAL_ERROR, stores nothing and no balance changes.
 */
function sum(balance: number, amount: number): number {
  const exact = BigInt(balance) + BigInt(amount)
  if (exact > BALANCE_LIMIT || exact < -BALANCE_LIMIT) throw new RangeError('The balance would leave its range.')
  return Number(exact)
}

/** The refusal of a rollback that names a transaction of another player or currency, else undefined. */
function ownerRefusal(owner: Owner | undefined, request: Owner): WalletAnswer | undefined {
  if (owner === undefined) return undefined
  if (owner.player_id !== request.player_id)
    return { error: { code: 'DUPLICATE_TRANSACTION', message: 'The transaction belongs to another player.' } }
  if (owner.currency !== request.currency)
    return { error: { code: 'CURRENCY_MISMATCH', message: 'The transaction is in another currency.' } }
  return undefined
}

/** Moves `amount` on a balance the wallet holds and records the move under its transaction_id, in the writing step. */
function moved(scope: WalletWriteScope, request: Move, balance: number, amount: number): WalletAnswer {
  const next = sum(balance, amount)
  return written(scope, () => {
    balances.set(keyOf(request.player_id, request.currency), next)
    moves.set(request.transaction_id, { player_id: request.player_id, currency: request.currency, amount })
    return { balance: next, currency: request.currency }
  })
}

const balanceOf = (player: string, currency: CurrencyCode): WalletAnswer => {
  const balance = balances.get(keyOf(player, currency))
  return balance === undefined ? UNKNOWN_PLAYER : { balance, currency }
}

const handlers: WalletHandlers = {
  walletBalance: async ({ player_id, currency }) => balanceOf(player_id, currency),
  walletBet: async (request, scope) => {
    const { player_id, currency, amount, transaction_id } = request
    // A rollback that arrived first leaves a tombstone: the late bet is refused.
    if (reversals.has(transaction_id)) return ROLLED_BACK_FIRST
    const balance = balances.get(keyOf(player_id, currency))
    if (balance === undefined) return UNKNOWN_PLAYER
    if (balance < amount) return { error: { code: 'INSUFFICIENT_FUNDS', message: 'The balance is too low.' } }
    return moved(scope, request, balance, -amount)
  },
  walletWin: async (request, scope) => {
    // The tombstone refuses a late win too.
    if (reversals.has(request.transaction_id)) return ROLLED_BACK_FIRST
    const balance = balances.get(keyOf(request.player_id, request.currency))
    if (balance === undefined) return UNKNOWN_PLAYER
    return moved(scope, request, balance, request.amount)
  },
  walletRollback: async (request, scope) => {
    const { player_id, currency, refers_to_transaction_id } = request
    const reversal = reversals.get(refers_to_transaction_id)
    const move = moves.get(refers_to_transaction_id)
    // Before a first answer is read or a balance moves: a rollback never reverses the move of another balance.
    const refused = ownerRefusal(reversal ?? move, request)
    if (refused !== undefined) return refused
    if (reversal !== undefined) return reversal.answer
    const balance = balances.get(keyOf(player_id, currency))
    if (balance === undefined) return UNKNOWN_PLAYER
    // An unknown transaction is acknowledged with the current balance; a reversed win may leave it below zero.
    const reversed = sum(balance, -(move?.amount ?? 0))
    return written(scope, () => {
      const answer: WalletAnswer = { balance: reversed, currency }
      balances.set(keyOf(player_id, currency), reversed)
      reversals.set(refers_to_transaction_id, { player_id, currency, answer })
      return answer
    })
  },
  // A round close moves no money, so it writes nothing and needs no scope.
  walletRoundFinished: async ({ player_id, currency }) => balanceOf(player_id, currency),
}

/** The wallet kit, served under /wallet, accepting the API keys of `secrets` (both keys during a rotation). */
export function walletKitOf(secrets: ReadonlyMap<string, string>): WalletKit {
  return createWalletKit({
    handlers,
    store: new MemoryReplayStore(),
    secretOf: (apiKey) => secrets.get(apiKey),
    basePath: '/wallet',
  })
}

The kit answers each operation at its path under basePath: /wallet/balance, /wallet/bet, /wallet/win, /wallet/rollback, /wallet/round-finished and /wallet/campaign-event. The wallet webhooks page describes what each operation means, and the Bet and Rollback reference pages list every field.

Serve it with the Fetch API

kit.handle(request) takes a standard Request and returns a Response, so Bun, Deno, Hono, Next route handlers and edge runtimes serve the kit as it is. It never rejects: a failing store or secret lookup is answered with INTERNAL_ERROR, which the aggregator retries.

server.ts

import { walletKitOf } from './wallet'

const kit = walletKitOf(new Map([['ak_REPLACE_WITH_YOUR_API_KEY_000000', 'REPLACE_WITH_YOUR_SIGNING_SECRET_0000000000']]))

/** The module shape Bun and Deno serve: every request goes to the kit. */
export default { fetch: (request: Request): Promise<Response> => kit.handle(request) }

Serve it with Express or Fastify

The signature covers the exact bytes of the body, so each adapter reads the body itself. Mount the Express handler before any body parser, express.json() included: a body another parser read first is refused with INTERNAL_ERROR, because its bytes can no longer be checked.

express-server.ts

import express from 'express'
import { expressWalletHandler } from '@asteriks-aggregator/sdk/wallet'
import { walletKitOf } from './wallet'

const kit = walletKitOf(new Map([['ak_REPLACE_WITH_YOUR_API_KEY_000000', 'REPLACE_WITH_YOUR_SIGNING_SECRET_0000000000']]))
const app = express()
app.use('/wallet', expressWalletHandler(kit))
app.use(express.json()) // the rest of the app may parse JSON after the wallet
app.listen(8080)

Register the Fastify plugin with the kit’s basePath as its prefix. Its own context reads every body raw, and the parsers of the rest of the app stay as they are.

fastify-server.ts

import Fastify from 'fastify'
import { fastifyWalletPlugin } from '@asteriks-aggregator/sdk/wallet'
import { walletKitOf } from './wallet'

const kit = walletKitOf(new Map([['ak_REPLACE_WITH_YOUR_API_KEY_000000', 'REPLACE_WITH_YOUR_SIGNING_SECRET_0000000000']]))
const app = Fastify()
await app.register(fastifyWalletPlugin(kit), { prefix: '/wallet' })
await app.listen({ port: 8080 })

Both adapters refuse a compressed body (Content-Encoding other than identity) and a body larger than WALLET_BODY_LIMIT_BYTES, 1 MiB, with MALFORMED_REQUEST. Pass { bodyLimit } as the second argument of expressWalletHandler or fastifyWalletPlugin to lower the limit.

What the kit refuses

The kit answers in the wallet’s error shape, {"error": {"code": "...", "message": "..."}}, with the HTTP status WALLET_ERROR_STATUS gives each code:

  • UNAUTHORIZED (401): the four signature headers are missing, the timestamp is outside the time window, the API key is unknown, the signature is wrong or the nonce was used before. The authentication page describes the signature and its time window.
  • MALFORMED_REQUEST (400): the body is not UTF-8 JSON, does not match the operation’s schema, or has a number with a fraction or an exponent (150.0, 1e2). Every amount is an integer, and such a number can be rounded to a different integer.
  • INTERNAL_ERROR (500): a handler threw or answered outside the contract, the store failed, or a balance answer has a currency other than the request currency. The kit does not store it, and the aggregator treats it as an unknown outcome. The kit gives the cause to your onError option and does not wait for it, and a failure of the hook does not change the answer. Your walletRollback handler writes the tombstone row by refers_to_transaction_id through scope.query; the Postgres bet sample below reads that row.
  • A path the kit does not serve is answered 404, and a method other than POST 405.

Repeats and the replay store

The aggregator sends every retry of an operation with the same transaction_id. The kit claims the transaction in its store before the handler runs, stores the first final answer and answers every repeat with the same bytes, so a handler never moves money twice; the idempotency page explains the rule. A repeat with the same transaction_id but another body is refused with DUPLICATE_TRANSACTION, and an INTERNAL_ERROR is never stored, so the next attempt runs afresh.

MemoryReplayStore keeps its records in the memory of one process and loses them when the process ends: use it for tests and for a wallet that runs as one process. A wallet that runs on several instances uses the Postgres store described below.

Postgres store

Use PostgresReplayStore when the wallet runs on more than one instance: it keeps the first answers and the nonces in Postgres, so every instance shares them. WALLET_REPLAY_SCHEMA creates its replay tables in a new database; apply it once, with your migrations or at start-up. From the first release on, a change to the tables ships as a numbered upgrade SQL script.

The store takes two functions of your driver: query, which runs one autocommit statement, and transaction, which runs work in one database transaction. Both take the statement text with positional parameters ($1, $2, ...) and resolve to its rows.

postgres-store.ts

import type { CurrencyCode } from '@asteriks-aggregator/sdk/api'
import {
  createWalletKit,
  PostgresReplayStore,
  type WalletHandlers,
  type WalletKit,
  type WalletQuery,
} from '@asteriks-aggregator/sdk/wallet'

type Unsafe = (text: string, parameters: string[]) => Promise<readonly Record<string, unknown>[]>

/** The two calls of a postgres.js client the store needs; node-postgres works the same way with a pool client. */
type Sql = { unsafe: Unsafe; begin<T>(work: (transaction: { unsafe: Unsafe }) => Promise<T>): Promise<T> }

/** The store over the operator's driver: `query` is one autocommit statement, `transaction` runs one transaction. */
export function postgresStoreOf(sql: Sql): PostgresReplayStore {
  return new PostgresReplayStore({
    query: (text, parameters) => sql.unsafe(text, [...parameters]),
    transaction: (work) =>
      sql.begin((transaction) => work((text, parameters) => transaction.unsafe(text, [...parameters]))),
  })
}

/** A bet that moves money only through `scope.query`: its write commits with the first answer, or not at all. */
export const walletBet: WalletHandlers['walletBet'] = async ({ player_id, currency, amount, transaction_id }, scope) => {
  const query: WalletQuery | undefined = scope.query
  if (query === undefined) throw new Error('The wallet kit needs the Postgres store for this handler.')
  // A rollback that arrived first left a tombstone: the late bet is refused and debits nothing.
  const tombstone = await query('SELECT 1 FROM tombstone WHERE transaction_id = $1', [transaction_id])
  if (tombstone.length > 0) {
    return { error: { code: 'DUPLICATE_TRANSACTION', message: 'The bet was rolled back before it arrived.' } }
  }
  // The debit matches the request currency: a ledger row in another currency moves no money.
  const rows = await query(
    'UPDATE ledger SET balance = balance - $3::bigint WHERE player = $1 AND currency = $2 AND balance >= $3::bigint RETURNING balance',
    [player_id, currency satisfies CurrencyCode, String(amount)],
  )
  const row = rows[0]
  if (row !== undefined) return { balance: Number(row.balance), currency } // the row matched the request currency
  const ledger = (await query('SELECT currency FROM ledger WHERE player = $1', [player_id]))[0]
  if (ledger === undefined) return { error: { code: 'PLAYER_NOT_FOUND', message: 'The player is not known.' } }
  if (ledger.currency !== currency)
    return { error: { code: 'CURRENCY_MISMATCH', message: "The player's wallet has another currency." } }
  return { error: { code: 'INSUFFICIENT_FUNDS', message: 'The balance is too low.' } }
}

/** The kit over the Postgres store; the other four handlers are written the same way. */
export function walletKitOf(handlers: WalletHandlers, sql: Sql, secrets: ReadonlyMap<string, string>): WalletKit {
  return createWalletKit({
    handlers: { ...handlers, walletBet },
    store: postgresStoreOf(sql),
    secretOf: (apiKey) => secrets.get(apiKey),
    basePath: '/wallet',
  })
}

A transaction_id’s handler runs inside its claim’s transaction. A bet, win, rollback or round-finished handler gets a second argument, scope, and moves money only through scope.query, so its writes commit with the first answer or not at all. A process that dies before it stores the answer has moved no money, and while a handler runs its claim is locked, so no other instance takes it over, however long the handler takes. A handler over records in memory (the in-memory store) writes only while scope.held() is true.

A handler answer with a 400 or a 401 is final: the kit stores it and replays it for a repeated transaction_id, like every other final answer (D-22). Only a 500 is never stored (D-23). What each store guarantees when a handler fails (it throws, or answers a 500):

  • PostgresReplayStore rolls the claim’s transaction back, so none of the handler’s scope.query writes are kept, and releases the claim: the provider’s retry is processed afresh. Money moves at most once, and a transient failure heals on the next retry.
  • MemoryReplayStore cannot undo writes to records in memory. A handler that asked scope.held() (it may have written) and then failed leaves its claim unresolved: the claim is never released or taken over, and every retry of that transaction_id is answered INTERNAL_ERROR (“still in flight”) until the process ends, never processed again. Money moves at most once. A handler that failed without asking scope.held() wrote nothing, so its claim is released and the retry is processed afresh. Ask scope.held() only in the step that writes.

Time limits

The kit sets no deadline on a handler. A cut-off in the kit would release the claim while the handler still runs, and the provider’s retry could then move money a second time. So the handler bounds its own time: give each database statement a timeout (for example SET LOCAL statement_timeout in the claim’s transaction), and fail when it expires. A failed handler is answered 500 and is not stored, so the retry is processed afresh. Keep the requestTimeout of your HTTP server set (Node.js sets 300 s by default): it closes a connection that sends its body too slowly.

Rotate an API key

secretOf returns the secret of an API key the wallet accepts, or undefined for any other key. During a rotation it returns a secret for both the old and the new key, so calls signed with either are accepted until the old key is revoked.

Was this page helpful?