SDK wallet test kit
The test kit of the SDK checks your wallet before go-live: it signs and sends every wallet operation and its edge cases to your wallet and reports pass or fail per case.
Run the command
The package installs the asteriks-wallet-test-kit command, which runs on Bun. Give it your wallet’s base URL, a test player and its currency, and set the API key and secret the aggregator signs your wallet calls with in two environment variables. Each case takes back the money it moved, so a run leaves the test player’s balance as it found it; the player needs a balance of at least WALLET_TEST_AMOUNT, 100 minor units.
ASTERIKS_WALLET_API_KEY=ak_REPLACE_WITH_YOUR_API_KEY_000000 ASTERIKS_WALLET_SECRET=REPLACE_WITH_YOUR_SIGNING_SECRET_0000000000 \
bunx asteriks-wallet-test-kit --url https://wallet.example.com/wallet --player player-1001 --currency EUR
The command prints one line per case and a summary, and exits 0 when every case passed, 1 when one failed and 2 on a usage error. A failed case is followed by its reason: the step and what the wallet answered.
asteriks wallet test kit: 19 cases against https://wallet.example.com/wallet
PASS balance_answer A balance call is answered in the player's currency.
FAIL stale_timestamp_refused A call whose timestamp is more than 30 seconds old is refused.
balance with a stale timestamp: expected the refusal UNAUTHORIZED, got an answer with balance 10000 EUR
...
18 passed, 1 failed
--list prints the cases without sending anything. --provider (default drakon), --game-id and --session-id set the fields every call carries, and --timeout-ms the deadline of every call but a bet, 10,000 ms by default; a bet always has 3,000 ms. Every call names its case in the unsigned header x-asteriks-test-case, so your wallet’s logs show which case sent it.
What the cases check
Each case judges one rule of the wallet contract; the wallet webhooks page describes each operation, and the Bet and Rollback reference pages list every field.
balance_answer: a balance call is answered in the player’s currency.bet_debits,win_credits,rollback_reverses_betandround_finished_answered: a bet debits its amount, a win credits its amount, also in a round with no bet, a rollback gives a bet’s amount back and a round close is answered.rollback_of_win_below_zero_accepted: a rollback of a win is accepted when the balance goes below zero, and is never refused withINSUFFICIENT_FUNDSorPLAYER_LIMIT_REACHED. The case first bets the whole balance to reach zero. If the wallet refuses that bet withINSUFFICIENT_FUNDSorPLAYER_LIMIT_REACHED, the case takes the win back. The case then fails withprecondition not met: cannot reach a zero balance. The rule was not checked. Use a test player whose whole balance is one bet the wallet accepts.repeat_returns_first_answerandmismatch_is_duplicate: a repeated transaction gets its first answer and moves no money, and atransaction_idused again with another amount is refused withDUPLICATE_TRANSACTION. The idempotency page explains the rule.unknown_rollback_acknowledgedandlate_bet_after_tombstone_refused: a rollback of an unknown transaction is acknowledged with the current balance, and the bet that arrives after it is refused withDUPLICATE_TRANSACTION.unknown_player_refusedandinsufficient_funds_refused: a call for a player the wallet does not hold is refused withPLAYER_NOT_FOUND, and a bet larger than the balance withINSUFFICIENT_FUNDSorPLAYER_LIMIT_REACHED.bad_signature_refused,stale_timestamp_refused,future_timestamp_refusedandreused_nonce_refused: a call signed with another secret, a call whose timestamp is 60 seconds old, a call whose timestamp is 60 seconds ahead and a call that reuses a nonce are each refused withUNAUTHORIZED. The authentication page describes the signature and its time window.malformed_body_refused: a correctly signed call whose body is not JSON, and one whose body breaks the operation's schema (a number forplayer_id), are each refused withMALFORMED_REQUEST.bet_within_deadline: a bet is answered within 3,000 ms,WALLET_BET_DEADLINE_MS.
Add a case of your own
The cases are one exported list, WALLET_TEST_CASES. runTestKitCli is the command itself: give it your own arguments, environment and output, and the shared cases with yours after them. A case calls the wallet through its context and returns { pass: true } or { pass: false, reason }; the outcome of a call is answered with the balance, refused with a canonical code, or unknown.
check-wallet.ts
import { runTestKitCli, WALLET_TEST_CASES, type WalletTestCase } from '@asteriks-aggregator/sdk/test-kit'
/** A case of your own beside the shared ones: the test player has money to bet with. */
const balanceIsPositive: WalletTestCase = {
id: 'balance_is_positive',
operation: 'balance',
title: 'The test player has money to bet with.',
run: async (context) => {
const { outcome } = await context.call('balance', {
player_id: context.player,
session_id: context.sessionId,
provider: context.provider,
game_id: context.gameId,
currency: context.currency,
})
return outcome.kind === 'answered' && outcome.balance > 0
? { pass: true }
: { pass: false, reason: `balance: ${outcome.kind}` }
},
}
/** Runs the shared cases and yours against the wallet at `url`; resolves with the exit status (0, 1 or 2). */
export function checkWallet(url: string, apiKey: string, secret: string, out: (line: string) => void): Promise<number> {
return runTestKitCli({
argv: ['--url', url, '--player', 'player-1001', '--currency', 'EUR'],
env: { ASTERIKS_WALLET_API_KEY: apiKey, ASTERIKS_WALLET_SECRET: secret },
out,
cases: [...WALLET_TEST_CASES, balanceIsPositive],
})
}
Run the cases from code
runWalletTestKit runs a list of cases in order and returns a report per case, so a test suite of your own can fail on a wallet change before it is deployed. Give it a WalletCaller per case, with your wallet’s base URL and credential, and a source of fresh ids: every id the kit sends is a UUID v7, so crypto.randomUUID, which makes a v4, will not do.
failed-cases.ts
import {
runWalletTestKit,
TEST_CASE_HEADER,
WALLET_TEST_CASES,
WalletCaller,
type WalletCredential,
type WalletTestReport,
} from '@asteriks-aggregator/sdk/test-kit'
/** The shared cases the wallet at `baseUrl` fails; `ids` gives a fresh UUID v7 per call. */
export async function failedCases(
baseUrl: string,
credential: WalletCredential,
ids: () => string,
): Promise<WalletTestReport[]> {
const reports = await runWalletTestKit({
cases: WALLET_TEST_CASES,
caller: (caseId) => new WalletCaller({ baseUrl, credential, headers: () => ({ [TEST_CASE_HEADER]: caseId }) }),
target: { player: 'player-1001', currency: 'EUR', provider: 'drakon', gameId: ids(), sessionId: ids(), ids },
})
return reports.filter((report) => !report.pass)
}
A case that throws fails with the error’s message and the run goes on. WalletCaller never follows a redirect, so a signed body cannot leave your wallet’s host, and it judges every answer as the aggregator does: a timeout, an INTERNAL_ERROR or an answer outside the contract is unknown, never a refusal. The SDK wallet kit guide shows a wallet that passes every case.
Test your handlers offline
The kit also tests your wallet without the live host and without a network. signedWalletRequest builds a signed bet, win, rollback or balance callback. It signs with the same code as the aggregator, so your wallet verifies it as it verifies a live callback. WALLET_FIXTURES are fixture bodies with their expected answers, in the order to send them. runWalletFixtures sends each fixture to a Fetch API handler and returns a report per fixture.
failed-fixtures.ts
import { fakeWalletCredential, runWalletFixtures, type WalletFixtureReport } from '@asteriks-aggregator/sdk/test-kit'
import { createWalletKit, MemoryReplayStore, type WalletHandlers } from '@asteriks-aggregator/sdk/wallet'
/** The fixtures that the wallet made from `handlers` fails, with no network and no real key. */
export async function failedFixtures(handlers: WalletHandlers): Promise<WalletFixtureReport[]> {
const credential = fakeWalletCredential()
const wallet = createWalletKit({
handlers,
store: new MemoryReplayStore(),
secretOf: (apiKey) => (apiKey === credential.apiKey ? credential.secret : undefined),
basePath: '/asteriks',
})
const reports = await runWalletFixtures({
handle: wallet.handle,
baseUrl: 'https://wallet.example.com/asteriks',
credential,
})
return reports.filter((report) => !report.pass)
}
Give the fixture player, WALLET_FIXTURE_PLAYER, its start balance of 1000 in EUR before the run. Use a new wallet state for each run: the fixture ids are fixed, so a second run against the same store sees repeats. The fixtures debit, credit, repeat a bet, roll back a bet and refuse a bet larger than the balance with INSUFFICIENT_FUNDS. checkWalletFixture compares one answer with its fixture and names what is wrong. fakeWalletCredential makes a random key and secret for each test. Never give a real key to the kit. The base URL includes your wallet's base path, because the signature covers the full path.
Run the self-test against an aggregator
The test kit also holds the self-test of a deployed aggregator: runAggregatorSelfTest plays the simulator providers games360, drakon and cocktail against it. For each one it opens a fresh session through your launcher and sends the balance, bet, win and rollback callbacks of that provider, and the round end where it sends one, each signed with the credentials the deployment is configured with; it judges every answer and returns a report per case, named <provider>_<operation>.
The aggregator runs it every 15 minutes against its development deployment, with a self-test client whose money no total counts and the reference operator as its wallet. It needs the secrets of each provider, so it is a tool for the aggregator team, not for operators. Before the first run the reference operator needs its test player: run bun run seed in examples/operator-wallet. Each case needs the test player to hold at least 301 minor units, its stake twice and a one-unit bet to move a shortfall back with; below that the case fails without moving money, and the run pings no heartbeat.
Give it the aggregator’s base URL, the credentials of each provider and a launcher that opens a session of the self-test client for a provider. Every id a Games360 call carries is a whole number on its wire, so id returns one; a case that fails or throws is reported and the run goes on.
self-test.ts
import {
runAggregatorSelfTest,
type SelfTestCredentials,
type SelfTestLauncher,
type SelfTestReport,
TEST_CASE_HEADER,
} from '@asteriks-aggregator/sdk/test-kit'
/** The cases the aggregator at `baseUrl` fails; `launcher` opens a session of the self-test client per provider. */
export async function failedSelfTestCases(
baseUrl: string,
credentials: SelfTestCredentials,
launcher: SelfTestLauncher,
): Promise<SelfTestReport[]> {
let nextId = Date.now()
const reports = await runAggregatorSelfTest({
baseUrl: new URL(baseUrl),
fetch: (url, init) => fetch(url, init),
credentials,
launcher,
nowSeconds: () => Math.floor(Date.now() / 1000),
nonce: () => crypto.randomUUID(),
id: () => String(nextId++),
headers: (caseId) => ({ [TEST_CASE_HEADER]: caseId }),
})
return reports.filter((report) => !report.pass)
}
Each case moves SELF_TEST_STAKE, 150 minor units, and moves it back, so a run leaves the test player’s balance as it found it. selfTestCaseIds lists the case ids of a provider without sending anything. The option cases (ids from selfTestCaseIds) runs only the listed cases, for example one failed case again. Each case has its own session, so it needs no case before it. An unknown id in cases throws a RangeError that names it, before any launch.