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, and a rollback reverses its bet or win once.
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 amount each bet and win moved, by transaction_id, so a rollback can reverse it. */
const moves = new Map<string, number>()
/** The first answer of each reversed transaction: one reversal per bet or win. */
const reversals = new Map<string, WalletAnswer>()
const keyOf = (player: string, currency: CurrencyCode) => `${player} ${currency}`
type Move = { readonly player_id: string; readonly currency: CurrencyCode; readonly transaction_id: string }
const UNKNOWN_PLAYER: WalletAnswer = { error: { code: 'PLAYER_NOT_FOUND', message: 'Unknown player.' } }
/**
* 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()
}
/** 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 {
return written(scope, () => {
balances.set(keyOf(request.player_id, request.currency), balance + amount)
moves.set(request.transaction_id, amount)
return { balance: balance + amount, 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 { error: { code: 'DUPLICATE_TRANSACTION', message: 'The bet was rolled back before it arrived.' } }
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) => {
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 ({ player_id, currency, refers_to_transaction_id }, scope) => {
const first = reversals.get(refers_to_transaction_id)
if (first !== undefined) return first
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 = balance - (moves.get(refers_to_transaction_id) ?? 0)
return written(scope, () => {
const answer: WalletAnswer = { balance: reversed, currency }
balances.set(keyOf(player_id, currency), reversed)
reversals.set(refers_to_transaction_id, 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 and /wallet/round-finished. 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([['your-api-key', 'your-api-secret']]))
/** 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([['your-api-key', 'your-api-secret']]))
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([['your-api-key', 'your-api-secret']]))
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 or does not match the operation’s schema.INTERNAL_ERROR(500): a handler threw or answered outside the contract, or the store failed. The aggregator treats it as an unknown outcome.- A path the kit does not serve is answered 404, and a method other than
POST405.
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 }, scope) => {
const query: WalletQuery | undefined = scope.query
if (query === undefined) throw new Error('The wallet kit needs the Postgres store for this handler.')
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 { error: { code: 'INSUFFICIENT_FUNDS', message: 'The balance is too low.' } }
return { balance: Number(row.balance), currency }
}
/** 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.
What each store guarantees when a handler fails (it throws, or answers a 400 or a 500, which are never stored):
PostgresReplayStorerolls the claim’s transaction back, so none of the handler’sscope.querywrites 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.MemoryReplayStorecannot undo writes to records in memory. A handler that askedscope.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 answeredINTERNAL_ERROR(“still in flight”) until the process ends, never processed again. Money moves at most once. A handler that failed without askingscope.held()wrote nothing, so its claim is released and the retry is processed afresh. Askscope.held()only in the step that writes.
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.