Live: https://kasodds.com/
Initial protocol implementation for the non-custodial KasOdds MVP on Kaspa.
The same image serves either Kaspa mainnet or testnet-10; the network is
selected at runtime with the single KASPA_NETWORK environment variable, and
the store file, wRPC node, and fee wallet all follow from it.
Both ways to play begin with an off-chain lobby. "Play someone new" queues the
wallet for matchmaking; "Play with a friend" opens a private room and the host
shares either a short six-character room code or a /join?room=<sessionId>
link, so an invite can be passed in places that block links. Funds are locked
only after the two players are matched: the creator signs the creation
transaction first, then the matched opponent signs the join. The invite carries
no secret, commitment preimage, wallet key, or transaction template.
Every game is a staked game: both players wager the same amount, and the
winner takes the pot. A stake may be any amount from 1 to 1,000,000 KAS,
fractions included down to the sompi (eight decimal places). Zero and anything
below 1 KAS is rejected — there is no free-play mode.
The covenant enforces the minimum on-chain, not just the app: every entry
requires stake >= 1 KAS. Both players fund their own escrow, so the joined
covenant holds grossPot = stake * 2. For a pot of at least 100 KAS, 1% goes to
the game wallet and the winner receives the remainder; smaller pots pay the
winner in full. The game fee is never charged without a winner.
src/network.jsis the single runtime registry of supported Kaspa networks. Each profile maps the SDK network id (mainnet,testnet-10) to its bech32 address prefix (kaspa,kaspatest) and KasWare network name (kaspa_mainnet,kaspa_testnet_10).server-config.jsresolves the profile once fromKASPA_NETWORKand injects it downward; domain code never reads the environment.src/protocol.jsvalidates sides, stake, sompi arithmetic, network, and fee separation.src/invite.jsparses and serializes the game-id invite; a friend room link is a/join?room=<sessionId>matchmaking session id instead.src/room-code.jsis the isomorphic friend-room code: it generates a short, unambiguous six-character handle (no0/O/1/I/L), normalizes typed input, and is shared by the server and the browser lobby. A code is only a lookup alias for a live private room; it is never a key or a wallet identifier.src/backend-game-service.jsis the production game use-case boundary. It owns creation, joining, reveal, refund, claim, matchmaking (the public queue and invite-only friend rooms), persistence, and recovery orchestration behind the HTTP application.src/create-game.js,src/join-game.js, andsrc/terminal-lifecycle.jsare exported protocol/lifecycle building blocks used by focused tests and library consumers; they are not the server's production request path.src/join-transactions.jsbuilds the join covenant input and doubled-pot continuation with ordinary joiner fee inputs kept separate.src/covenant-artifact.jsvalidates a pinned SilverScript artifact before it can be used. SilverScript compilation is intentionally a build-time concern; the browser consumes the resulting artifact.src/covenant/kasodds.mjsderives the per-game covenant instance: it loads the pinned artifact, substitutes the game state into the template state span, verifies the template hash, and produces the P2SH-256 script and the network-prefixed address (kaspa:on mainnet,kaspatest:on testnet-10). Output is byte-for-byte cross-validated against the authoritative Rustcovenant-oracle(seeoracle/).src/chain-adapter.jsprovides the productionKaspaChainAdaptergateway between the game use cases and the chain:prepareCreation(UTXOs + live priority feerate + local mass/relay floor policy viasrc/fee-policy.js),confirmCreationgated on one DAA confirmation, andprepareJoin(doubled-pot continuation with ordinary joiner fee inputs).src/terminal-actions.jsreduces confirmed game state into fallback-claim and individual-refund eligibility, including DAA deadlines, race precedence, and fail-closed user-facing outcomes.src/terminal-transactions.jsbuilds KCC entry scripts and Rusty Kaspa v2 SafeJSON templates for reveal-adjacent terminal actions, claims, and refunds; the browser still signs only the prepared transaction.src/recovery.jsreconstructs game state from accepted chain history with a one-confirmation buffer, invalidates removed-block checkpoints, classifies external transactions, and provides memory and durable JSON recovery stores.public/app.jsis the browser composition root.public/app-controller.jsowns cancellable polling and stale-response protection; browser secrets and IndexedDB remain behindpublic/secrets.js.src/backend-game-store.jspersists game records, matchmaking sessions (including invite-only friend rooms), and non-secret transaction preparations atomically on disk. Player reveal preimages are never stored here; they live only in the short-lived in-memorysrc/ephemeral-preparations.js. The one exception is the fallback bot's own preimage, which the server keeps underbotSecretsuntil that game ends.src/wasm-transaction.jsloads the pinned WASM SDK (Transaction,GenesisCovenantGroup,populateGenesisCovenants,serializeToSafeJSON) and rejects any wallet mutation of sighash-relevant fields.src/genesis-transaction.jscomputes the Rusty Kaspa v2 covenant ID, constructs output zero, proves exact fee separation, and rejects any wallet SafeJSON mutation outside input signature scripts.- The Rust
covenant-oracle(oracle/, built from the pinned rusty-kaspa v2.0.1 reva41a333b…) is a real-runtime regression oracle for the P2SH-256 script, covenant address, state span, template hash, and genesis covenant id (seetest/covenant-oracle-runtime.test.js). It depends on the vendoredsilverscriptsubmodule (silverscript-abiby path) without modifying upstream code.
The canonical covenant artifact is compiled by the silverc binary
from SilverScript v1.0.0 (whose emitted artifact/compiler identifier remains
0.1.0) from covenant/kasodds.sil into
covenant/kasodds.template.artifact.json. The artifact is identical on both
networks: only the bech32 address prefix differs (Toccata covenants are live on
mainnet and testnet-10, and the pinned WASM SDK is the mainnet Toccata release).
- contract:
KasOdds, template hashade3453c…7e27a - state span:
offset 1, len 261(13 fields:creator_hash,joiner_hash,creator_commit,joiner_commit,stake,deadline_daa,creator_even,creator_choice,joiner_choice,first_revealer_hash,game_wallet_hash,status,settle_fee) - dispatch tags:
join = b1d2ce8f,refund = 762ffa55,refund_open = 3a658a5b - terminal dispatch tags:
reveal = 6b547798,fallback_claim = e8bae487,refund_all = 0e2b436c - P2SH-256:
0xaa 0x20 <blake2b-256(redeemScript)>; address prefix is the configured network's (kaspaorkaspatest), version byte 8. - reproducibility manifest:
covenant/pins.jsonpins the SilverScript release, source commit, emitted compiler version, plus source, artifact, and local Windows compiler SHA-256 values.
The ABI, state layout, compiler revision, covenant artifact, Rusty Kaspa v2.0.1
WASM release checksum, fee-input selection, and fee-rate policy are all pinned
before real funds are accepted. See covenant/pins.json (rustyKaspa.status = "pinned", wasmReleaseSha256, vendoredWasmFileSha256).
The obsolete npm kaspa-wasm@0.13.0 package is intentionally not used for
covenant transaction construction.
npm test
npm run checksilverscript/ is a pinned git submodule (upstream kaspanet/silverscript at
v1.0.0). The covenant-oracle-runtime tests need its silverscript-abi
crate, so initialize the submodule and build the standalone oracle before
running them:
git submodule update --init
cd oracle && cargo build --release && cd ..The repository includes a production container and Kubernetes manifests under
deploy/. The runtime process exposes Kubernetes probe endpoints only; the
production request path is src/server.js -> src/http-application.js ->
src/backend-game-service.js. src/index.js is a convenience aggregate for
library consumers, not the server entry point. See
docs/architecture.md for the dependency direction and
recovery boundaries.
git push to main is the deploy button. CI builds the linux/arm64 image,
smoke-tests the published artifact against /readyz and /metrics, pushes the
immutable sha-<commit> tag to GHCR, and commits the exact published digest
into deploy/deployment.yaml (deploy: sha-<commit>), preserving the source
commit in the kasodds/source-revision annotation. The generated commit
touches only deploy/deployment.yaml, which is excluded from the workflow
trigger, so the delivery flow terminates without recursing. Argo CD syncs the
cluster to Git — prune + selfHeal keep Git authoritative — so the pod rolls
to the new digest automatically. CI never talks to Kubernetes and holds no
cluster credential; there is no second deploy path.
Pull requests run the full validation plus an ARM64 image build (without
publishing). Several pushes in quick succession cancel obsolete in-flight
builds so the newest revision wins, and CI refuses to advance the manifest if a
newer source push has already landed on main.
Local verification mirrors the CI gate:
npm run check
npm test
docker build --platform linux/arm64 -t ghcr.io/danieliyahu1/kas-odds/kasodds:sha-<git-sha> .deploy/deployment.yaml pins the immutable image for the current release; the
image line is updated by CI, never by hand. Deleting a file under deploy/
removes the corresponding object from the cluster (Argo prunes it).
Runtime details:
- Namespace:
kasodds - Public port:
3000; internal metrics port:9464 - Readiness endpoint:
/readyz(returns 503 unless the state volume is both readable and writable and the store parses as valid JSON) - Liveness endpoint:
/healthz(process liveness only) - Required runtime secrets: none beyond the fee wallet identity. The fee wallet
is a public address — never a literal in this repository. In the cluster the
Deployment reads it from the
kasodds-game-fee-addressSecret (keysmainnetandtestnet-10, filled from the OCI Vault entrieskasodds-game-fee-address-mainnetandkasodds-game-fee-address-testnet-10) viavalueFrom.secretKeyRef; locally the same values are set with--env-file=.env(the.envfile is gitignored). Player wallet private keys never leave the browser. - Optional fallback bot: when
BOT_PRIVATE_KEY_MAINNET(a 32-byte hex key) is set, a public searcher who finds no human is offered a bot after a five-second grace period. The bot holds the single server-side key, takes the joiner seat in one game at a time, and always plays for the 1 KAS minimum. It picks a random number and nonce when it joins and stores them in the game store until that game ends, so it can always reveal and always finishes a game it started. The key name is network-qualified and scoped to the network it funds (BOT_PRIVATE_KEY_TESTNET_10on testnet), so a mainnet key can never be used on testnet and vice versa. When the key for the active network is unset the bot is off and is never offered. In the cluster the mainnet key comes from thekasodds-botSecret (keymainnet, from the OCI Vault entrykasodds-bot-private-key-mainnet) viavalueFrom.secretKeyRefwithoptional: true; testnet is not mapped in the Deployment, so the bot is mainnet-only there. - Required network:
KASPA_NETWORKis the single switch and must bemainnetortestnet-10— the process fails closed when it is unset or unknown. Each profile fixes the address prefix (kaspa/kaspatest) and the KasWare network name (kaspa_mainnet/kaspa_testnet_10), and the SDK resolver picks the matching wRPC node.KASPA_WRPC_URLis an optional override that pins a specific node. Every other network-specific value (the store file and the fee wallet, below) follows fromKASPA_NETWORK, so switching networks changes exactly one variable. The browser never talks to a node directly; all chain reads, fee estimation, transaction preparation, and broadcast happen server-side. - The conditional on-chain game fee: each network has its own recipient wallet,
supplied as
GAME_FEE_ADDRESS_MAINNETandGAME_FEE_ADDRESS_TESTNET_10(a sharedGAME_FEE_ADDRESSis still the fallback when the qualified name is absent), soKASPA_NETWORKalone decides which wallet is used. Both live in thekasodds-game-fee-addressSecret — keysmainnetandtestnet-10— which the ExternalSecret fills from the OCI Vault entrieskasodds-game-fee-address-mainnetandkasodds-game-fee-address-testnet-10by name, so no value is ever in Git. The wallet receives 1% of the total locked pot when the pot is at least 100 KAS and the game settles with a winner (second reveal or fallback claim). Smaller pots have no platform fee. Kaspa version-0 (PubKey) addresses embed the recipient's x-only public key directly, so the server decodes the address at startup and bakes that key into every game's covenant state. Each player locks exactly the displayed stake; automatic timeout refunds reserve 0.016 KAS from the locked amount for the network fee, while winner settlement pays the game fee from the total pot. The fee recipient is runtime configuration: the process boots without one, reportsgameFeePublicKey: nullfrom/api/config, and rejects game creation withINVALID_GAME_FEEuntil it is configured — so a misconfigured pod never serves a game without a fee recipient. The Deployment reads the keys withvalueFrom.secretKeyRef, so the pod is not created when the Secret is missing, and each address prefix must matchKASPA_NETWORK(a mismatched prefix is rejected at startup). - Required persistent storage: the
kasodds-statePVC mounted at/var/lib/kasoddsstores non-secret backend game metadata. The store file is derived from the network —GAME_STORE_DIRplusgames-<network>-v10.json— so a network switch never points two networks at one file;GAME_STORE_PATHremains an explicit override, and locally it defaults under.data/. The volume isReadWriteOnceand only ever mounted by a single replica; the Deployment usesstrategy: Recreatefor that reason. - Request controls:
MAX_REQUEST_BYTES(default 1,000,000) caps request bodies, andRATE_LIMIT_PER_MINUTE(default 300) caps mutating API calls per client. SetTRUST_PROXY=trueonly behind a trusted proxy that rewritesx-forwarded-for. The in-memory relay expires entries after 10 minutes and caps payloads and entry count; the server holds no reveal secret. - Logging:
LOG_LEVEL(defaultinfo;debugadds static-asset requests) andLOG_FORMAT(textorjson). Each request logs its method, route template, status, duration, and any protocol error code/message. Logs never contain request bodies, wallet addresses, keys, nonces, signatures, or transaction ids. For a debug session only,LOG_WALLET_ADDRESSES=1reveals full wallet addresses on every server operation while still redacting keys, nonces, signatures, commitments, and bodies; leave it unset in production. In the browser, add?debug=1(or setlocalStorage['kasodds-debug'] = '1') for verbose[kasodds]console tracing of the wallet flow; warnings and errors are always printed. - Feedback: the top-bar Feedback button posts anonymous feedback to
POST /api/feedback. The server validates it (1–1,500 characters), writes it to the state volume before anything can fail, and forwards it to a private Telegram chat viasendMessage(parse_modeoff, web preview disabled). The bot token (TELEGRAM_FEEDBACK_BOT_TOKEN) and chat id (TELEGRAM_FEEDBACK_CHAT_ID) are runtime-only configuration read from thekasodds-telegramSecret (keysbot-tokenandchat-id, filled from the OCI Vault entrieskasodds-telegram-bot-tokenandkasodds-telegram-chat-id). Feedback is just the message the user wrote — no wallet address, game id, transaction, page, or query string is attached, and the text is never logged. When Telegram is not configured the feedback is still stored in the queue — never discarded — records akasodds_feedback_total{outcome="disabled"}metric, and logs afeedback_delivery_disabledwarning so a missing bot is noticed without breaking the app; it is delivered automatically the next time the app starts with the bot configured. Deliveries that fail are queued atFEEDBACK_SPILL_PATH(default/var/lib/kasodds/feedback-spill.json) and retried on startup and every two minutes until they land, so outages never lose a message. A per-client limit of five submissions per ten minutes keeps the channel spam-free.
Observability:
- The serving process exposes Prometheus metrics on
9464at/metrics(requests, errors, latency, wRPC calls, store operations, matchmaking backlog, relay entries, process memory). The public Service does not expose this port. deploy/metrics-service.yamlanddeploy/vmservicescrape.yamlregister the scrape target with the VictoriaMetrics operator.deploy/grafana-dashboard.yamlprovisions the "KasOdds" dashboard into theobservabilitynamespace via thegrafana_dashboard: "1"label.
The browser is a thin client and the server coordinates every game. The hidden number and nonce are generated in the browser and never leave it until reveal:
public/secrets.jsstores each game's hidden number in IndexedDB under a fresh 32-bytecrypto.getRandomValuesnonce, saved before any funds are locked. Only the commitment hash is sent to the server. The secret is deleted once the game settles, is claimed, or is refunded.public/verify.jsindependently re-derives the covenant and checks the prepared creation output before KasWare is asked to sign, so a compromised server cannot substitute a different commitment, side, stake, or covenant.public/app.jsdrives the flow: create → reveal → refund/claim, signing each server-prepared transaction with KasWare and returning it for broadcast.src/wasm-loader.mjsloads the pinned Rusty Kaspa v2.0.1 SDK in Node and verifies the WASM binary against the pinned SHA-256 before use.src/covenant/kasodds-core.mjsis the isomorphic,Buffer-free covenant derivation;src/covenant/template.mjssupplies the pinned artifact.- The server sends a strict
Content-Security-Policy(same-origin scripts,wasm-unsafe-eval,connect-src 'self', no objects/frames) as defense-in-depth against XSS reading the browser-local reveal secret.
The commit-reveal covenant enforces the result on-chain. The server sees only the commitment hash at create/join time. It learns the number and nonce only when it prepares the reveal transaction — after both commitments are confirmed on-chain and the number is public by design — so a server that also plays as a player cannot change its committed number after seeing an opponent's.
Both paths use the same off-chain lobby. A public game is "play up to": each
player sets the most they are comfortable playing, the server pairs any two
waiters, and the stake is the lower of the two limits. A friend game is a
private room: the host fixes the stake and shares a short room code or a
/join?room=<sessionId> link, and only the friend with that code or link can
take the second seat. A code is valid only while the room is live and is capped
per client, so it cannot be guessed open. In both
cases the server assigns each player a side at match time, and no funds move
until both players pick a number and lock: the assigned creator signs the
creation transaction to escrow their stake, and the matched opponent signs the
join to take the other side. The optional fallback bot is the one exception to
"no server-side signing": it holds its own funded key to take the joiner seat in
a single public game at a time, is always labelled as the bot, and never enters a
friend room. Until a join confirms,
the creator can reclaim their full stake at any time by signing the refund
spend (the server builds and relays it), and after the deadline the
permissionless refund_open entry can reclaim the creator's stake with no
signature, reserving the network fee from that lock. The server builds and
broadcasts every transaction, so it is required for the normal flow; the
covenant still enforces the reveal/claim/refund timeouts on-chain regardless
of who broadcasts.
Every client is a wallet that submits covenant-valid transactions, and confirmed KasOdds covenant state is authoritative. There is no account and no separate queue; the browser is one client, and a script, bot, or agent can drive the same HTTP API. The optional fallback bot is the only server-held key, and it plays through the same matchmaking and covenant path as any other wallet.
See docs/http-api.md for the endpoints, the commit-reveal
encoding, signing requirements, error codes, timeouts, and idempotency.
If you like this repo, you can tip me at https://kas.coffee/danieliyahu.