A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
16 KiB
Smirk Wallet Integration
How Wowngeon integrates with the Smirk browser extension for wallet-based sign-in, payments, and signed chat.
Smirk injects a frozen window.smirk object on every page, in the style of window.ethereum. Sites
use it to request public keys, request signatures, read wallet addresses, and prompt an in-extension
payment. Wowngeon uses all four.
Enablement
Smirk is opt-in and mainnet-only:
SMIRK_ENABLED=truemust be set (seesrc/.env.example).src/auth/smirkPolicy.jsforces the feature off on any test network (stagenet/testnet), regardless ofSMIRK_ENABLED.- The auth routes in
src/routes/auth.jsare only mounted when the policy returns true (src/index.js). - The server advertises the resolved state to clients as
smirkEnabledon thegame_mode_infosocket event. The client fails closed:SmirkAuth.init()returns early unlessSocketHandlers._smirkEnabled === true.
Authentication
Two proof shapes are accepted. The client prefers NIP-98 and falls back to the legacy Ed25519 path when the wallet does not advertise nostr signing, or when nostr signing fails for any reason other than an explicit user cancellation.
Every auth call is session-gated. The caller must send the browser's session bearer token in the
X-Session-Token header (users.anon_token, stored client-side as wownerogue_token). The token
proves ownership of the socket, which by itself is public: Socket.IO ids are broadcast in Tavern and
match state, so a socket id alone can never authorize attaching a wallet key.
Client Server Extension
| | |
|-- POST /challenge ----------->| |
| X-Session-Token, socketId | |
|<------- { challenge } --------| |
| | |
|-- smirk.signNostrEvent(evt) ------------------------------->|
|<---------------- signed kind:27235 event -------------------|
| | |
|-- POST /verify -------------->| |
| X-Session-Token, | |
| { socketId, event } | |
|<---- { linked: true, ... } ---| |
| | |
|-- smirk.getAddresses() ------------------------------------>|
|<---------------- { wow: "Wo..." } -------------------------|
| | |
|-- socket: address:update ---->| |
POST /api/auth/smirk/challenge
Issue a single-use nonce.
Headers: X-Session-Token: <session token>
Request:
{ "socketId": "<socket.io id>" }
Response:
{ "challenge": "<64-char hex string>", "expiresIn": 300 }
The challenge is 32 random bytes, hex encoded, inserted into smirk_challenges bound to both the
socket id and the stable users.id resolved from the session token. It expires after five minutes.
Each call also prunes expired rows and consumed rows older than one hour.
Rate limit: 10 requests per minute per IP.
POST /api/auth/smirk/verify
Verify a wallet proof and link the wallet to the account.
Headers: X-Session-Token: <session token>
Request (NIP-98, preferred):
{
"socketId": "<socket.io id>",
"event": { "kind": 27235, "created_at": 0, "pubkey": "", "id": "", "sig": "", "content": "", "tags": [] }
}
Request (legacy Ed25519):
{
"socketId": "<socket.io id>",
"challenge": "<the challenge string>",
"publicKey": "<Ed25519 public key, hex>",
"signature": "<Ed25519 signature, hex>"
}
The route selects the NIP-98 path when event is a non-array object, and the legacy path otherwise.
Response (wallet linked to the current account):
{ "success": true, "linked": true, "message": "Smirk wallet linked successfully" }
Response (wallet already owned an account, so the caller is signed in to it):
{
"success": true,
"linked": true,
"adopted": true,
"sessionToken": "<rotated bearer token for the wallet's account>",
"address": "<that account's payout address or null>",
"message": "Signed in to your wallet-linked account."
}
Errors are returned as { "error": "<CODE>", "message": "<safe text>" }:
| Status | Code | Cause |
|---|---|---|
| 400 | VALIDATION_ERROR |
Missing or oversized socketId, missing proof fields, invalid/expired/already-used challenge, unverifiable event or signature |
| 401 | SESSION_TOKEN_REQUIRED |
No X-Session-Token header |
| 403 | SESSION_OWNERSHIP_REQUIRED |
Token does not own the given socketId |
| 409 | WALLET_ADOPTION_CONFLICT, SOCKET_OWNERSHIP_CONFLICT |
Concurrent sign-in raced the adoption transaction; retry |
| 429 | rate limited | More than 20 verify attempts per minute from one IP; Retry-After is set |
GET /api/auth/smirk/status
Report whether the caller's own account has a linked wallet.
Query: ?socketId=<socket.io id>
Headers: X-Session-Token: <session token>
Response:
{ "linked": true, "hasPayoutAddress": true }
The caller is identified by the session token alone, because socket_id is volatile and changes on
every refresh or reconnect; matching on the unguessable token gives the same guarantee (a caller can
only read its own state) without breaking after a reload. socketId remains a required query
parameter for API shape. A missing token returns 401 and an unknown token returns 403; neither
discloses anything about another account.
Account adoption
smirk_public_key carries a unique index over non-null values, so one wallet key maps to at most one
account. When the proven key already belongs to a different account, verification does not fail.
Instead a single transaction:
- Re-locks and re-checks the presented session row.
- Revokes the caller's temporary anonymous account (clears its
socket_id, rotates its token). - Rotates the wallet owner's bearer token and assigns the caller's socket to that account.
- Confirms exactly one account now owns the socket.
The response carries the rotated token as sessionToken with adopted: true. Live sockets for both
accounts are disconnected after the response is sent, so nothing keeps running under stale identity.
The client stores the new token in localStorage and reloads, which re-establishes credits, payout
address, and history as the wallet's account.
Proof verification
NIP-98 (src/utils/nip98.js)
The challenge nonce is consumed first, atomically. A single
UPDATE ... WHERE used = FALSE ... RETURNING flips the row for exactly one caller, so concurrent
requests cannot both pass and there is no select-then-update window. The event is then verified
against that exact nonce.
Verification requires all of:
- Well-formed kind
27235event: numerickindandcreated_at, 64-hexpubkeyandid, 128-hexsig, stringcontent, andtagsas an array of string arrays. - The event id independently recomputed with
getEventHashand matching the claimed id. This catches content or tag tampering without relying on any verification memoization inside nostr-tools, which stamps its result onto the event object. - A valid BIP-340 schnorr signature over secp256k1.
|now - created_at| <= 120seconds.- Exactly one
methodtag equal toPOST. - Exactly one
utag whose URL path ends with/api/auth/smirk/verifyand whose host equals the expected host. - Exactly one
challengetag equal to the consumed nonce.
The expected host comes from HOSTED_BY when configured, falling back to the request's own host for
development and self-hosted deployments. The host check is a hard requirement, not a nicety: path
matching alone would accept an event the same wallet signed for any other site whose path ends the
same way, and because verification adopts whichever account owns the key and returns its session
token, a borrowed event would be a full account takeover.
Verification is a pure function with no I/O, so it round-trips in unit tests against nostr-tools.
The proven pubkey is an x-only secp256k1 nostr key, a different key namespace from the legacy
Ed25519 spend key, so a NIP-98 sign-in registers the account under the nostr key.
Legacy Ed25519
The nonce is consumed the same way, then the signature is verified over the raw UTF-8 bytes of the challenge string per RFC 8032:
const nacl = require('tweetnacl');
const challengeBytes = Buffer.from(challenge, 'utf8');
const signatureBytes = Buffer.from(signature, 'hex');
const publicKeyBytes = Buffer.from(publicKey, 'hex');
const valid = nacl.sign.detached.verify(challengeBytes, signatureBytes, publicKeyBytes);
There is no SHA256 pre-hash and no hex-decode of the challenge: the bytes signed are the bytes of the challenge string itself.
window.smirk API reference
Detection
if (typeof window.smirk !== 'undefined') {
// Extension is installed
}
Capabilities are feature-detected individually. Nostr support is detected as
typeof window.smirk.signNostrEvent === 'function' && typeof window.smirk.getNostrPublicKey === 'function'.
smirk.connect() -> Promise<PublicKeys>
Requests user approval to share public keys. Opens an approval popup on first use; later calls from an approved origin return keys immediately.
const keys = await window.smirk.connect();
// keys.wow - Ed25519 public spend key (hex)
// keys.btc - secp256k1 compressed public key (hex)
// keys.ltc - secp256k1 compressed public key (hex)
// keys.xmr - Ed25519 public spend key (hex)
// keys.grin - Ed25519 public key (hex)
smirk.signMessage(message) -> Promise<SignResult>
Signs a message with all wallet keys. Always prompts for user approval.
const result = await window.smirk.signMessage(challenge);
const wowSig = result.signatures.find(s => s.asset === 'wow');
// wowSig.signature - 64-byte Ed25519 signature (hex)
// wowSig.publicKey - public key that signed (hex)
Message limit: 10,000 characters.
smirk.getNostrPublicKey() -> Promise<string>
Returns the wallet's x-only nostr public key and grants the origin the Nostr scope. The grant is one-time per origin.
smirk.signNostrEvent(template) -> Promise<Event>
Signs a nostr event template, filling in pubkey, id, and sig. Throws NOT_AUTHORIZED when the
origin does not yet hold the Nostr scope. Both call sites therefore sign first and only call
getNostrPublicKey() on that error, so a returning user sees one approval rather than two. The
wallet still asks per signature for kind 27235; that prompt cannot be suppressed.
smirk.getAddresses() -> Promise<Addresses>
Returns wallet addresses for all assets. Requires prior connect().
const addrs = await window.smirk.getAddresses();
// addrs.wow - "Wo..." (97-char CryptoNote standard address)
// addrs.btc - "bc1q..." (bech32 P2WPKH)
// addrs.ltc - "ltc1q..." (bech32 P2WPKH)
// addrs.xmr - "4..." (95-char CryptoNote standard address)
// addrs.grin - "grin1..." (bech32 slatepack address)
smirk.requestPayment(options) -> Promise<PaymentResult>
Prompts the user to send a payment. Opens an approval popup showing the recipient address, amount, and description. Resolves once the user confirms and the transaction is broadcast; rejects if the user denies or the extension context is invalid.
const result = await window.smirk.requestPayment({
address: 'Wo3MWeLE...', // Recipient address
amount: '1', // Human-readable amount, not atomic units
asset: 'wow', // Asset ticker, lowercase
description: 'Single game entry' // Shown in the approval popup
});
// result.txid - Transaction hash (hex)
// result.amount - Amount sent (string)
amount is the human-readable value, for example "1" for 1 WOW. The extension converts to atomic
units internally.
Rejection cases: the user denies the popup, or the extension context is invalidated (the browser
reloaded the extension), which rejects with "Extension context invalidated" and requires a page
refresh to re-establish the connection.
smirk.isConnected() -> Promise<boolean>
Whether the current origin is approved.
smirk.disconnect() -> Promise<void>
Revoke site access.
smirk.getPublicKeys() -> Promise<PublicKeys | null>
Public keys without prompting. Returns null when the origin is not connected.
Client implementation
Sign-in: html/js/network/smirkAuth.js.
SmirkAuth.init()runs whengame_mode_infoarrives, with a 3-second fallback timer, and only when the server advertisedsmirkEnabled === true. Double initialization is guarded.- With the extension present it renders a connect button and calls
checkStatus()to show the already-linked state on load; without it, an install link tohttps://smirk.cashis shown. login()fetches a challenge, runs the NIP-98 or legacy proof, and on anadoptedresponse stores the returnedsessionTokenand reloads the page.- Otherwise it calls
getAddresses()and emitsaddress:updatewithaddresses.wowto set the payout address. A failure here is non-fatal: the user is told to set the address manually. - A
LOCKEDerror is surfaced as an unlock prompt rather than a failure, because current wallet builds open the unlock popup fromconnect()andsignNostrEvent()while older builds throw.
Payments: _trySmirkPayment in html/js/network/socketHandlers.js.
- On the
payment_createdevent, the client usesrequestPayment()whenSmirkAuth._isLinkedis true, the extension is present, and it exposesrequestPayment. - The amount sent is
data.humanAmount || data.amountFormatted || String(data.amount), and the asset is the active crypto type lowercased. The description varies by payment type: game entry, credits package, or cosmetic pack. - On success the normal server-side mempool monitoring completes the flow; the UI moves to a waiting-for-confirmation state.
- On rejection the client falls back to the address and QR modal. A user denial keeps the linked
state; any other error clears
SmirkAuth._isLinkedso later payments go straight to the manual flow.
Chat signing: html/js/smirkChatSign.js.
smirkChatSign(socket, text, tag)signs a kind:1 event carrying attag (defaultwowngeon-global) and emits it aschat_signed.- It resolves
falsewhenever signing is unavailable or declined, so callers fall back to the normal unsigned send. Feature detection is end to end, which makes wiring it into a send site safe even when no wallet is connected. - The server verifies the event in
src/utils/verifyChatEvent.jsand binds the author to the npub the session authenticated as, so a player can only post as themselves. A valid signature by some other key is rejected.
Database
smirk_challenges holds pending nonces (migrations 009_smirk_integration.sql and
036_smirk_challenge_user_binding.sql):
| Column | Type | Description |
|---|---|---|
| id | serial | Primary key |
| challenge | varchar(64), unique | Hex challenge string |
| socket_id | varchar(255) | Socket.IO session id the nonce was issued to |
| user_id | integer, not null | Owning users.id, cascade delete |
| used | boolean | Whether the nonce has been consumed |
| expires_at | timestamp | Defaults to NOW() + 5 minutes |
| created_at | timestamp | Row creation time |
users.smirk_public_key (varchar(128)) stores the linked key, with a unique index over non-null
values. Migration 036 also adds a unique index on non-null users.socket_id, making single socket
ownership structural; the migration aborts if duplicate rows exist.
Signature schemes by asset
| Asset | Curve | Hash | Format |
|---|---|---|---|
| BTC | secp256k1 (ECDSA) | Double SHA256 with Bitcoin message prefix | Compact signature (64 bytes) |
| LTC | secp256k1 (ECDSA) | Double SHA256 with Bitcoin message prefix | Compact signature (64 bytes) |
| XMR | Ed25519 | Raw message bytes (RFC 8032) | R || s (64 bytes) |
| WOW | Ed25519 | Raw message bytes (RFC 8032) | R || s (64 bytes) |
| GRIN | Ed25519 | Raw message bytes (RFC 8032) | R || s (64 bytes) |
Nostr signing (signNostrEvent) is separate from all of these: BIP-340 schnorr over secp256k1 with
an x-only public key.
Wowngeon's legacy sign-in path uses only the WOW signature. The NIP-98 path uses only the nostr key.