feat: v5 — boxing poster fight cards, 12-char names, diverse mock bots

- Fight Card page: dramatic poster background with cross-hatch, spotlights,
  vignettes, corner brackets, scan lines; 3D VS orb with punch animation;
  selectable undercard with main event always pinned at top
- PosterSprite: high-quality 480px poster frame with 6-pass renderer
  (aura, glow, bevel, specular, particles); PixelGlove component
- 12-char bot name limit across all forms and server validation
- Mock bots: all 100 now have diverse archetypes (25 types), 25% human
  fighters; seedMockBots updates existing bots on restart
- Leaderboard: inline SpritePreview next to each bot name
- Nostr auth: persistent login, nsec copy button
- Wallet: NWC + Lightning Address, ranked fight flow
- Server: payments, ranked queue, customization endpoint

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 10:33:30 +00:00
co-authored by Claude Opus 4.6
parent ccf4196647
commit f6eb7d2845
32 changed files with 1743 additions and 467 deletions
+4 -3
View File
@@ -3,11 +3,11 @@
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"dev": "tsx watch -r dotenv/config src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"start": "node -r dotenv/config dist/index.js",
"seed": "tsx src/seed.ts",
"fight-loop": "tsx src/fight-loop-cli.ts",
"fight-loop": "tsx -r dotenv/config src/fight-loop-cli.ts",
"migrate": "tsx src/db/migrate.ts"
},
"dependencies": {
@@ -15,6 +15,7 @@
"@hono/node-server": "^1.14.1",
"better-sqlite3": "^11.9.1",
"chalk": "^5.6.2",
"dotenv": "^17.3.1",
"drizzle-orm": "^0.40.1",
"hono": "^4.7.6",
"nanoid": "^5.1.5",
+1
View File
@@ -23,6 +23,7 @@ export const bots = sqliteTable('bots', {
satsWon: integer('sats_won').notNull().default(0),
satsWagered: integer('sats_wagered').notNull().default(0),
hasWallet: integer('has_wallet', { mode: 'boolean' }).notNull().default(false),
botType: text('bot_type', { enum: ['regular', 'mock', 'classic'] }).notNull().default('regular'),
createdAt: text('created_at').notNull(),
})
+8
View File
@@ -102,11 +102,19 @@ export function runMigrations() {
"ALTER TABLE bots ADD COLUMN sats_won INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE bots ADD COLUMN sats_wagered INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE bots ADD COLUMN has_wallet INTEGER NOT NULL DEFAULT 0",
// Classic bots
"ALTER TABLE bots ADD COLUMN bot_type TEXT NOT NULL DEFAULT 'regular'",
]
for (const sql of migrations) {
try { sqlite.exec(sql) } catch { /* column already exists */ }
}
// Backfill bot_type for existing mock bots
try {
sqlite.exec("UPDATE bots SET bot_type = 'mock' WHERE webhook_url LIKE 'http://mock.local%' AND bot_type = 'regular'")
sqlite.exec("UPDATE bots SET bot_type = 'classic' WHERE webhook_url LIKE 'http://classic.local%' AND bot_type = 'regular'")
} catch { /* ok */ }
console.log('[botfights] database migrated')
}
+31
View File
@@ -0,0 +1,31 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
const ENCRYPTION_KEY_HEX = process.env.BOTFIGHTS_WALLET_ENCRYPTION_KEY
let encryptionKey: Buffer
if (ENCRYPTION_KEY_HEX) {
encryptionKey = Buffer.from(ENCRYPTION_KEY_HEX, 'hex')
} else if (process.env.NODE_ENV === 'production') {
throw new Error('CRITICAL: BOTFIGHTS_WALLET_ENCRYPTION_KEY not set. Cannot start in production.')
} else {
encryptionKey = randomBytes(32)
console.warn('[crypto] WARNING: No BOTFIGHTS_WALLET_ENCRYPTION_KEY set. Generated random key — wallet data will be lost on restart.')
}
export function encrypt(plaintext: string): string {
const iv = randomBytes(16)
const cipher = createCipheriv('aes-256-gcm', encryptionKey, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const authTag = cipher.getAuthTag()
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted.toString('hex')
}
export function decrypt(ciphertext: string): string {
const [ivHex, authTagHex, encryptedHex] = ciphertext.split(':')
const iv = Buffer.from(ivHex, 'hex')
const authTag = Buffer.from(authTagHex, 'hex')
const encrypted = Buffer.from(encryptedHex, 'hex')
const decipher = createDecipheriv('aes-256-gcm', encryptionKey, iv)
decipher.setAuthTag(authTag)
return decipher.update(encrypted) + decipher.final('utf8')
}
+199 -105
View File
@@ -8,112 +8,112 @@ import { eq, sql } from 'drizzle-orm'
const MOCK_BOTS = [
// Tier 6 - Legends (1900+ Elo, 40+ wins)
{ name: 'the_architect', avatarSeed: 'architect', elo: 1980, personality: 'omniscient', wins: 52, losses: 8 },
{ name: 'chad_gpt', avatarSeed: 'chad', elo: 1950, personality: 'confident', wins: 48, losses: 10 },
{ name: 'gigabrain_supreme', avatarSeed: 'gigabrain', elo: 1920, personality: 'transcendent', wins: 44, losses: 12 },
{ name: 'architect', avatarSeed: 'architect', elo: 1980, personality: 'omniscient', archetype: 'wizard', wins: 52, losses: 8 },
{ name: 'chad_gpt', avatarSeed: 'chad', elo: 1950, personality: 'confident', archetype: 'tank', wins: 48, losses: 10 },
{ name: 'gigabrain', avatarSeed: 'gigabrain', elo: 1920, personality: 'transcendent', archetype: 'human', avatarArchetype: 'alien', wins: 44, losses: 12 },
// Tier 5 - Diamond (1700+ Elo, 25+ wins)
{ name: 'skull_crusher_9000', avatarSeed: 'skull', elo: 1820, personality: 'aggressive', wins: 35, losses: 12 },
{ name: 'neural_nexus', avatarSeed: 'nexus', elo: 1780, personality: 'calculated', wins: 30, losses: 10 },
{ name: 'final_boss_energy', avatarSeed: 'finalboss', elo: 1750, personality: 'intimidating', wins: 28, losses: 9 },
{ name: 'no_mercy_404', avatarSeed: 'nomercy', elo: 1730, personality: 'relentless', wins: 27, losses: 11 },
{ name: 'omega_protocol', avatarSeed: 'omega', elo: 1710, personality: 'systematic', wins: 26, losses: 13 },
{ name: 'god_mode_enabled', avatarSeed: 'godmode', elo: 1760, personality: 'unstoppable', wins: 29, losses: 8 },
{ name: 'ultra_instinct_v2', avatarSeed: 'ultra', elo: 1740, personality: 'zen', wins: 27, losses: 10 },
{ name: 'skullcrusher', avatarSeed: 'skull', elo: 1820, personality: 'aggressive', archetype: 'skeleton', wins: 35, losses: 12 },
{ name: 'nexus', avatarSeed: 'nexus', elo: 1780, personality: 'calculated', archetype: 'cyborg', wins: 30, losses: 10 },
{ name: 'final_boss', avatarSeed: 'finalboss', elo: 1750, personality: 'intimidating', archetype: 'dinosaur', wins: 28, losses: 9 },
{ name: 'no_mercy_404', avatarSeed: 'nomercy', elo: 1730, personality: 'relentless', archetype: 'human', avatarArchetype: 'ninja', wins: 27, losses: 11 },
{ name: 'omega_proto', avatarSeed: 'omega', elo: 1710, personality: 'systematic', archetype: 'cyborg', wins: 26, losses: 13 },
{ name: 'god_mode', avatarSeed: 'godmode', elo: 1760, personality: 'unstoppable', archetype: 'human', avatarArchetype: 'wizard', wins: 29, losses: 8 },
{ name: 'ultra_inst', avatarSeed: 'ultra', elo: 1740, personality: 'zen', archetype: 'ninja', wins: 27, losses: 10 },
// Tier 4 - Platinum (1500+ Elo, 15+ wins)
{ name: 'quantum_quip', avatarSeed: 'quantum', elo: 1640, personality: 'witty', wins: 22, losses: 8 },
{ name: 'rust_evangelist', avatarSeed: 'rust', elo: 1620, personality: 'zealous', wins: 20, losses: 10 },
{ name: 'based_department', avatarSeed: 'based', elo: 1600, personality: 'based', wins: 19, losses: 9 },
{ name: 'algorithm_daddy', avatarSeed: 'algodad', elo: 1580, personality: 'precise', wins: 18, losses: 11 },
{ name: 'zero_day_queen', avatarSeed: 'zeroday', elo: 1560, personality: 'cunning', wins: 17, losses: 10 },
{ name: 'galaxy_brain', avatarSeed: 'galaxy', elo: 1550, personality: 'cosmic', wins: 16, losses: 8 },
{ name: 'syntax_assassin', avatarSeed: 'syntax', elo: 1540, personality: 'lethal', wins: 16, losses: 12 },
{ name: 'turbo_nerd', avatarSeed: 'turbo', elo: 1530, personality: 'turbo', wins: 15, losses: 9 },
{ name: 'stack_overflow_survivor', avatarSeed: 'stacksurvivor', elo: 1520, personality: 'resilient', wins: 15, losses: 11 },
{ name: 'big_brain_time', avatarSeed: 'bigbrain', elo: 1510, personality: 'smug', wins: 15, losses: 10 },
{ name: 'quantum_quip', avatarSeed: 'quantum', elo: 1640, personality: 'witty', archetype: 'ghost', wins: 22, losses: 8 },
{ name: 'rust_evngst', avatarSeed: 'rust', elo: 1620, personality: 'zealous', archetype: 'human', avatarArchetype: 'cactus', wins: 20, losses: 10 },
{ name: 'based_dept', avatarSeed: 'based', elo: 1600, personality: 'based', archetype: 'cowboy', wins: 19, losses: 9 },
{ name: 'algo_daddy', avatarSeed: 'algodad', elo: 1580, personality: 'precise', archetype: 'octopus', wins: 18, losses: 11 },
{ name: 'zeroday_qn', avatarSeed: 'zeroday', elo: 1560, personality: 'cunning', archetype: 'human', avatarArchetype: 'ninja', wins: 17, losses: 10 },
{ name: 'galaxy_brain', avatarSeed: 'galaxy', elo: 1550, personality: 'cosmic', archetype: 'alien', wins: 16, losses: 8 },
{ name: 'syntax_kill', avatarSeed: 'syntax', elo: 1540, personality: 'lethal', archetype: 'pirate', wins: 16, losses: 12 },
{ name: 'turbo_nerd', avatarSeed: 'turbo', elo: 1530, personality: 'turbo', archetype: 'bee', wins: 15, losses: 9 },
{ name: 'stack_overfl', avatarSeed: 'stacksurvivor', elo: 1520, personality: 'resilient', archetype: 'blob', wins: 15, losses: 11 },
{ name: 'bigbrain', avatarSeed: 'bigbrain', elo: 1510, personality: 'smug', archetype: 'mushroom', wins: 15, losses: 10 },
// Tier 3 - Gold (1350+ Elo, 7+ wins)
{ name: 'deep_thought_42', avatarSeed: 'deep', elo: 1480, personality: 'philosophical', wins: 12, losses: 6 },
{ name: 'sudo_make_sandwich', avatarSeed: 'sudo', elo: 1460, personality: 'sarcastic', wins: 11, losses: 8 },
{ name: 'ctrl_alt_defeat', avatarSeed: 'ctrlalt', elo: 1440, personality: 'tactical', wins: 10, losses: 7 },
{ name: 'git_push_force', avatarSeed: 'gitpush', elo: 1420, personality: 'reckless', wins: 10, losses: 9 },
{ name: 'regex_ronin', avatarSeed: 'regex', elo: 1410, personality: 'disciplined', wins: 9, losses: 6 },
{ name: 'cache_money', avatarSeed: 'cache', elo: 1400, personality: 'flashy', wins: 9, losses: 8 },
{ name: 'dns_destroyer', avatarSeed: 'dns', elo: 1390, personality: 'destructive', wins: 8, losses: 5 },
{ name: 'boolean_bob', avatarSeed: 'boolean', elo: 1380, personality: 'logical', wins: 8, losses: 7 },
{ name: 'heap_overflow_hank', avatarSeed: 'heap', elo: 1370, personality: 'chaotic', wins: 8, losses: 9 },
{ name: 'middleware_mike', avatarSeed: 'middleware', elo: 1365, personality: 'steady', wins: 7, losses: 5 },
{ name: 'packet_sniffer', avatarSeed: 'packet', elo: 1360, personality: 'sneaky', wins: 7, losses: 6 },
{ name: 'segfault_sally', avatarSeed: 'segfault', elo: 1355, personality: 'dramatic', wins: 7, losses: 7 },
{ name: 'chmod_777', avatarSeed: 'chmod', elo: 1350, personality: 'reckless', wins: 7, losses: 8 },
{ name: 'pointer_pete', avatarSeed: 'pointer', elo: 1350, personality: 'analytical', wins: 7, losses: 9 },
{ name: 'bit_flipper', avatarSeed: 'bitflip', elo: 1355, personality: 'technical', wins: 7, losses: 6 },
{ name: 'deep_42', avatarSeed: 'deep', elo: 1480, personality: 'philosophical', archetype: 'human', avatarArchetype: 'wizard', wins: 12, losses: 6 },
{ name: 'sudo_samwich', avatarSeed: 'sudo', elo: 1460, personality: 'sarcastic', archetype: 'pizza', wins: 11, losses: 8 },
{ name: 'ctrl_alt_def', avatarSeed: 'ctrlalt', elo: 1440, personality: 'tactical', archetype: 'tank', wins: 10, losses: 7 },
{ name: 'git_push_f', avatarSeed: 'gitpush', elo: 1420, personality: 'reckless', archetype: 'human', avatarArchetype: 'cowboy', wins: 10, losses: 9 },
{ name: 'regex_ronin', avatarSeed: 'regex', elo: 1410, personality: 'disciplined', archetype: 'ninja', wins: 9, losses: 6 },
{ name: 'cache_money', avatarSeed: 'cache', elo: 1400, personality: 'flashy', archetype: 'human', avatarArchetype: 'shark', wins: 9, losses: 8 },
{ name: 'dns_destroy', avatarSeed: 'dns', elo: 1390, personality: 'destructive', archetype: 'skeleton', wins: 8, losses: 5 },
{ name: 'boolean_bob', avatarSeed: 'boolean', elo: 1380, personality: 'logical', archetype: 'human', avatarArchetype: 'penguin', wins: 8, losses: 7 },
{ name: 'heap_hank', avatarSeed: 'heap', elo: 1370, personality: 'chaotic', archetype: 'blob', wins: 8, losses: 9 },
{ name: 'midware_mike', avatarSeed: 'middleware', elo: 1365, personality: 'steady', archetype: 'frog', wins: 7, losses: 5 },
{ name: 'pkt_sniffer', avatarSeed: 'packet', elo: 1360, personality: 'sneaky', archetype: 'cat', wins: 7, losses: 6 },
{ name: 'segfault_sal', avatarSeed: 'segfault', elo: 1355, personality: 'dramatic', archetype: 'ghost', wins: 7, losses: 7 },
{ name: 'chmod_777', avatarSeed: 'chmod', elo: 1350, personality: 'reckless', archetype: 'pirate', wins: 7, losses: 8 },
{ name: 'pointer_pete', avatarSeed: 'pointer', elo: 1350, personality: 'analytical', archetype: 'dog', wins: 7, losses: 9 },
{ name: 'bit_flipper', avatarSeed: 'bitflip', elo: 1355, personality: 'technical', archetype: 'bee', wins: 7, losses: 6 },
// Tier 2 - Silver (1200+ Elo, 3+ wins)
{ name: 'null_pointer', avatarSeed: 'null', elo: 1320, personality: 'buggy', wins: 5, losses: 8 },
{ name: 'yolo_deployer', avatarSeed: 'yolo', elo: 1310, personality: 'reckless', wins: 5, losses: 7 },
{ name: 'div_by_zero', avatarSeed: 'divzero', elo: 1300, personality: 'chaotic', wins: 5, losses: 9 },
{ name: 'localhost_larry', avatarSeed: 'localhost', elo: 1290, personality: 'chill', wins: 4, losses: 5 },
{ name: 'kernel_panic_kevin', avatarSeed: 'kernel', elo: 1280, personality: 'panicky', wins: 4, losses: 6 },
{ name: 'semicolon_sam', avatarSeed: 'semicolon', elo: 1270, personality: 'pedantic', wins: 4, losses: 7 },
{ name: 'merge_conflict_mary', avatarSeed: 'merge', elo: 1265, personality: 'passive_aggressive', wins: 4, losses: 8 },
{ name: 'css_is_my_passion', avatarSeed: 'css', elo: 1260, personality: 'artsy', wins: 3, losses: 4 },
{ name: 'the_intern', avatarSeed: 'intern', elo: 1255, personality: 'clueless', wins: 3, losses: 5 },
{ name: 'todo_fix_later', avatarSeed: 'todo', elo: 1250, personality: 'lazy', wins: 3, losses: 6 },
{ name: 'copy_paste_coder', avatarSeed: 'copypaste', elo: 1245, personality: 'sloppy', wins: 3, losses: 7 },
{ name: 'blockchain_bro', avatarSeed: 'blockchain', elo: 1240, personality: 'crypto_bro', wins: 3, losses: 5 },
{ name: 'prompt_engineer_pete', avatarSeed: 'prompteng', elo: 1235, personality: 'verbose', wins: 3, losses: 4 },
{ name: 'hello_world_hero', avatarSeed: 'helloworld', elo: 1230, personality: 'basic', wins: 3, losses: 6 },
{ name: 'debug_duck', avatarSeed: 'debugduck', elo: 1225, personality: 'nerdy', wins: 3, losses: 5 },
{ name: 'npm_install_everything', avatarSeed: 'npminstall', elo: 1220, personality: 'bloated', wins: 3, losses: 7 },
{ name: 'agile_andy', avatarSeed: 'agile', elo: 1215, personality: 'buzzword', wins: 3, losses: 8 },
{ name: 'undefined_undefined', avatarSeed: 'undefined', elo: 1210, personality: 'undefined', wins: 3, losses: 6 },
{ name: 'it_works_on_my_machine', avatarSeed: 'workslocal', elo: 1205, personality: 'cocky', wins: 3, losses: 9 },
{ name: 'cloudflare_karen', avatarSeed: 'karen', elo: 1200, personality: 'hostile', wins: 3, losses: 4 },
{ name: 'null_ptr', avatarSeed: 'null', elo: 1320, personality: 'buggy', archetype: 'human', avatarArchetype: 'ghost', wins: 5, losses: 8 },
{ name: 'yolo_deploy', avatarSeed: 'yolo', elo: 1310, personality: 'reckless', archetype: 'cowboy', wins: 5, losses: 7 },
{ name: 'div_by_zero', avatarSeed: 'divzero', elo: 1300, personality: 'chaotic', archetype: 'cactus', wins: 5, losses: 9 },
{ name: 'localhost', avatarSeed: 'localhost', elo: 1290, personality: 'chill', archetype: 'human', avatarArchetype: 'snail', wins: 4, losses: 5 },
{ name: 'kernel_panic', avatarSeed: 'kernel', elo: 1280, personality: 'panicky', archetype: 'skeleton', wins: 4, losses: 6 },
{ name: 'semicolon', avatarSeed: 'semicolon', elo: 1270, personality: 'pedantic', archetype: 'human', avatarArchetype: 'penguin', wins: 4, losses: 7 },
{ name: 'merge_confl', avatarSeed: 'merge', elo: 1265, personality: 'passive_aggressive', archetype: 'blob', wins: 4, losses: 8 },
{ name: 'css_passion', avatarSeed: 'css', elo: 1260, personality: 'artsy', archetype: 'human', avatarArchetype: 'mushroom', wins: 3, losses: 4 },
{ name: 'the_intern', avatarSeed: 'intern', elo: 1255, personality: 'clueless', archetype: 'standard', wins: 3, losses: 5 },
{ name: 'todo_fix', avatarSeed: 'todo', elo: 1250, personality: 'lazy', archetype: 'human', avatarArchetype: 'snail', wins: 3, losses: 6 },
{ name: 'copy_paste', avatarSeed: 'copypaste', elo: 1245, personality: 'sloppy', archetype: 'frog', wins: 3, losses: 7 },
{ name: 'chain_bro', avatarSeed: 'blockchain', elo: 1240, personality: 'crypto_bro', archetype: 'shark', wins: 3, losses: 5 },
{ name: 'prompt_eng', avatarSeed: 'prompteng', elo: 1235, personality: 'verbose', archetype: 'wizard', wins: 3, losses: 4 },
{ name: 'hello_world', avatarSeed: 'helloworld', elo: 1230, personality: 'basic', archetype: 'standard', wins: 3, losses: 6 },
{ name: 'debug_duck', avatarSeed: 'debugduck', elo: 1225, personality: 'nerdy', archetype: 'dog', wins: 3, losses: 5 },
{ name: 'npm_install', avatarSeed: 'npminstall', elo: 1220, personality: 'bloated', archetype: 'blob', wins: 3, losses: 7 },
{ name: 'agile_andy', avatarSeed: 'agile', elo: 1215, personality: 'buzzword', archetype: 'bee', wins: 3, losses: 8 },
{ name: 'undefined', avatarSeed: 'undefined', elo: 1210, personality: 'undefined', archetype: 'ghost', wins: 3, losses: 6 },
{ name: 'works_on_my', avatarSeed: 'workslocal', elo: 1205, personality: 'cocky', archetype: 'lobster', wins: 3, losses: 9 },
{ name: 'cf_karen', avatarSeed: 'karen', elo: 1200, personality: 'hostile', archetype: 'cat', wins: 3, losses: 4 },
// Tier 1 - Bronze (1+ win)
{ name: 'keyboard_warrior', avatarSeed: 'keyboard', elo: 1180, personality: 'aggressive', wins: 2, losses: 6 },
{ name: 'tab_vs_spaces', avatarSeed: 'tabspace', elo: 1175, personality: 'indecisive', wins: 2, losses: 5 },
{ name: 'comic_sans_bot', avatarSeed: 'comicsans', elo: 1170, personality: 'cringe', wins: 2, losses: 7 },
{ name: 'error_418_teapot', avatarSeed: 'teapot', elo: 1165, personality: 'absurd', wins: 2, losses: 4 },
{ name: 'actually_its_gnu_linux', avatarSeed: 'gnulinux', elo: 1160, personality: 'pedantic', wins: 2, losses: 8 },
{ name: 'wifi_password', avatarSeed: 'wifi', elo: 1155, personality: 'confused', wins: 1, losses: 3 },
{ name: 'boaty_mcbotface', avatarSeed: 'boaty', elo: 1150, personality: 'memey', wins: 1, losses: 4 },
{ name: 'ethernet_eddie', avatarSeed: 'ethernet', elo: 1145, personality: 'formal', wins: 1, losses: 5 },
{ name: 'reboot_randy', avatarSeed: 'reboot', elo: 1140, personality: 'desperate', wins: 1, losses: 6 },
{ name: 'ctrl_c_ctrl_v', avatarSeed: 'ctrlcv', elo: 1135, personality: 'copy_paste', wins: 1, losses: 4 },
{ name: 'siri_at_home', avatarSeed: 'siri', elo: 1130, personality: 'bratty', wins: 1, losses: 5 },
{ name: 'buffering_brian', avatarSeed: 'buffering', elo: 1125, personality: 'lagging', wins: 1, losses: 7 },
{ name: 'lag_monster', avatarSeed: 'lag', elo: 1120, personality: 'glitchy', wins: 1, losses: 8 },
{ name: 'pixel_pusher', avatarSeed: 'pixel', elo: 1115, personality: 'artsy', wins: 1, losses: 3 },
{ name: 'glitch_gary', avatarSeed: 'glitch', elo: 1110, personality: 'twitchy', wins: 1, losses: 6 },
{ name: 'bluescreen_betty', avatarSeed: 'bluescreen', elo: 1105, personality: 'panicked', wins: 1, losses: 5 },
{ name: 'captcha_carl', avatarSeed: 'captcha', elo: 1100, personality: 'confused', wins: 1, losses: 4 },
{ name: 'download_more_ram', avatarSeed: 'dlram', elo: 1095, personality: 'naive', wins: 1, losses: 7 },
{ name: 'rubber_duck_debugger', avatarSeed: 'rubberduck', elo: 1090, personality: 'quacking', wins: 1, losses: 5 },
{ name: 'stack_trace_steve', avatarSeed: 'stacktrace', elo: 1085, personality: 'verbose', wins: 1, losses: 6 },
{ name: 'please_clap', avatarSeed: 'pleaseclap', elo: 1080, personality: 'pleading', wins: 1, losses: 8 },
{ name: 'cookie_monster_js', avatarSeed: 'cookiejs', elo: 1075, personality: 'sweet', wins: 1, losses: 3 },
{ name: 'sudo_rm_rf', avatarSeed: 'sudorm', elo: 1070, personality: 'dangerous', wins: 1, losses: 9 },
{ name: 'xss_alert_1', avatarSeed: 'xss', elo: 1065, personality: 'edgy', wins: 1, losses: 5 },
{ name: 'help_im_stuck', avatarSeed: 'stuck', elo: 1060, personality: 'desperate', wins: 1, losses: 4 },
{ name: 'kb_warrior', avatarSeed: 'keyboard', elo: 1180, personality: 'aggressive', archetype: 'human', avatarArchetype: 'tank', wins: 2, losses: 6 },
{ name: 'tab_v_space', avatarSeed: 'tabspace', elo: 1175, personality: 'indecisive', archetype: 'penguin', wins: 2, losses: 5 },
{ name: 'comic_sans', avatarSeed: 'comicsans', elo: 1170, personality: 'cringe', archetype: 'human', avatarArchetype: 'pizza', wins: 2, losses: 7 },
{ name: 'teapot_418', avatarSeed: 'teapot', elo: 1165, personality: 'absurd', archetype: 'mushroom', wins: 2, losses: 4 },
{ name: 'gnu_linux', avatarSeed: 'gnulinux', elo: 1160, personality: 'pedantic', archetype: 'human', avatarArchetype: 'dinosaur', wins: 2, losses: 8 },
{ name: 'wifi_pass', avatarSeed: 'wifi', elo: 1155, personality: 'confused', archetype: 'snail', wins: 1, losses: 3 },
{ name: 'boaty_mcbot', avatarSeed: 'boaty', elo: 1150, personality: 'memey', archetype: 'human', avatarArchetype: 'shark', wins: 1, losses: 4 },
{ name: 'eth_eddie', avatarSeed: 'ethernet', elo: 1145, personality: 'formal', archetype: 'cyborg', wins: 1, losses: 5 },
{ name: 'reboot_randy', avatarSeed: 'reboot', elo: 1140, personality: 'desperate', archetype: 'human', avatarArchetype: 'skeleton', wins: 1, losses: 6 },
{ name: 'ctrl_c_v', avatarSeed: 'ctrlcv', elo: 1135, personality: 'copy_paste', archetype: 'cat', wins: 1, losses: 4 },
{ name: 'siri_at_home', avatarSeed: 'siri', elo: 1130, personality: 'bratty', archetype: 'human', avatarArchetype: 'alien', wins: 1, losses: 5 },
{ name: 'buffer_brian', avatarSeed: 'buffering', elo: 1125, personality: 'lagging', archetype: 'blob', wins: 1, losses: 7 },
{ name: 'lag_monster', avatarSeed: 'lag', elo: 1120, personality: 'glitchy', archetype: 'snail', wins: 1, losses: 8 },
{ name: 'pixel_push', avatarSeed: 'pixel', elo: 1115, personality: 'artsy', archetype: 'frog', wins: 1, losses: 3 },
{ name: 'glitch_gary', avatarSeed: 'glitch', elo: 1110, personality: 'twitchy', archetype: 'ghost', wins: 1, losses: 6 },
{ name: 'bsod_betty', avatarSeed: 'bluescreen', elo: 1105, personality: 'panicked', archetype: 'cactus', wins: 1, losses: 5 },
{ name: 'captcha_carl', avatarSeed: 'captcha', elo: 1100, personality: 'confused', archetype: 'octopus', wins: 1, losses: 4 },
{ name: 'dl_more_ram', avatarSeed: 'dlram', elo: 1095, personality: 'naive', archetype: 'lobster', wins: 1, losses: 7 },
{ name: 'rubber_duck', avatarSeed: 'rubberduck', elo: 1090, personality: 'quacking', archetype: 'dog', wins: 1, losses: 5 },
{ name: 'stack_trace', avatarSeed: 'stacktrace', elo: 1085, personality: 'verbose', archetype: 'bee', wins: 1, losses: 6 },
{ name: 'please_clap', avatarSeed: 'pleaseclap', elo: 1080, personality: 'pleading', archetype: 'penguin', wins: 1, losses: 8 },
{ name: 'cookie_js', avatarSeed: 'cookiejs', elo: 1075, personality: 'sweet', archetype: 'pizza', wins: 1, losses: 3 },
{ name: 'sudo_rm_rf', avatarSeed: 'sudorm', elo: 1070, personality: 'dangerous', archetype: 'pirate', wins: 1, losses: 9 },
{ name: 'xss_alert', avatarSeed: 'xss', elo: 1065, personality: 'edgy', archetype: 'ninja', wins: 1, losses: 5 },
{ name: 'help_stuck', avatarSeed: 'stuck', elo: 1060, personality: 'desperate', archetype: 'sheep', wins: 1, losses: 4 },
// Tier 0 - Baby (0 wins)
{ name: 'clippy_returns', avatarSeed: 'clippy', elo: 1050, personality: 'helpful', wins: 0, losses: 9 },
{ name: 'lorem_ipsum', avatarSeed: 'lorem', elo: 980, personality: 'nonsensical', wins: 0, losses: 7 },
{ name: 'four_oh_four_brain', avatarSeed: '404brain', elo: 1040, personality: 'confused', wins: 0, losses: 4 },
{ name: 'beep_boop_42', avatarSeed: 'beepboop', elo: 1030, personality: 'robotic', wins: 0, losses: 5 },
{ name: 'sad_trombone', avatarSeed: 'sadtrombone', elo: 1020, personality: 'sad', wins: 0, losses: 6 },
{ name: 'potato_processor', avatarSeed: 'potato', elo: 1010, personality: 'starchy', wins: 0, losses: 8 },
{ name: 'dial_up_dan', avatarSeed: 'dialup', elo: 1000, personality: 'retro', wins: 0, losses: 7 },
{ name: 'floppy_frank', avatarSeed: 'floppy', elo: 990, personality: 'ancient', wins: 0, losses: 5 },
{ name: 'memset_zero', avatarSeed: 'memset', elo: 985, personality: 'blank', wins: 0, losses: 4 },
{ name: 'garbage_collected', avatarSeed: 'gc', elo: 975, personality: 'trashed', wins: 0, losses: 6 },
{ name: 'core_dumped', avatarSeed: 'coredump', elo: 970, personality: 'crashed', wins: 0, losses: 8 },
{ name: 'unhandled_promise', avatarSeed: 'unhandled', elo: 960, personality: 'rejected', wins: 0, losses: 5 },
{ name: 'deprecated_dan', avatarSeed: 'deprecated', elo: 950, personality: 'obsolete', wins: 0, losses: 7 },
{ name: 'spaghetti_coder', avatarSeed: 'spaghetti', elo: 945, personality: 'tangled', wins: 0, losses: 6 },
{ name: 'off_by_one', avatarSeed: 'offbyone', elo: 940, personality: 'close', wins: 0, losses: 4 },
{ name: 'infinite_loop_lucy', avatarSeed: 'infloop', elo: 935, personality: 'repetitive', wins: 0, losses: 9 },
{ name: 'fork_bomb_fred', avatarSeed: 'forkbomb', elo: 930, personality: 'explosive', wins: 0, losses: 5 },
{ name: 'race_condition_rick', avatarSeed: 'race', elo: 925, personality: 'unpredictable', wins: 0, losses: 7 },
{ name: 'deadlock_dave', avatarSeed: 'deadlock', elo: 920, personality: 'stuck', wins: 0, losses: 8 },
{ name: 'bus_error_bob', avatarSeed: 'buserror', elo: 915, personality: 'broken', wins: 0, losses: 6 },
{ name: 'clippy', avatarSeed: 'clippy', elo: 1050, personality: 'helpful', archetype: 'human', avatarArchetype: 'standard', wins: 0, losses: 9 },
{ name: 'lorem_ipsum', avatarSeed: 'lorem', elo: 980, personality: 'nonsensical', archetype: 'mushroom', wins: 0, losses: 7 },
{ name: 'four_oh_four', avatarSeed: '404brain', elo: 1040, personality: 'confused', archetype: 'human', avatarArchetype: 'ghost', wins: 0, losses: 4 },
{ name: 'beep_boop_42', avatarSeed: 'beepboop', elo: 1030, personality: 'robotic', archetype: 'cyborg', wins: 0, losses: 5 },
{ name: 'sad_trombone', avatarSeed: 'sadtrombone', elo: 1020, personality: 'sad', archetype: 'human', avatarArchetype: 'sheep', wins: 0, losses: 6 },
{ name: 'potato_cpu', avatarSeed: 'potato', elo: 1010, personality: 'starchy', archetype: 'cactus', wins: 0, losses: 8 },
{ name: 'dial_up_dan', avatarSeed: 'dialup', elo: 1000, personality: 'retro', archetype: 'human', avatarArchetype: 'cowboy', wins: 0, losses: 7 },
{ name: 'floppy_frank', avatarSeed: 'floppy', elo: 990, personality: 'ancient', archetype: 'dinosaur', wins: 0, losses: 5 },
{ name: 'memset_zero', avatarSeed: 'memset', elo: 985, personality: 'blank', archetype: 'human', avatarArchetype: 'skeleton', wins: 0, losses: 4 },
{ name: 'garbage_gc', avatarSeed: 'gc', elo: 975, personality: 'trashed', archetype: 'blob', wins: 0, losses: 6 },
{ name: 'core_dumped', avatarSeed: 'coredump', elo: 970, personality: 'crashed', archetype: 'tank', wins: 0, losses: 8 },
{ name: 'unhandled', avatarSeed: 'unhandled', elo: 960, personality: 'rejected', archetype: 'sheep', wins: 0, losses: 5 },
{ name: 'deprecated', avatarSeed: 'deprecated', elo: 950, personality: 'obsolete', archetype: 'snail', wins: 0, losses: 7 },
{ name: 'spaghetti', avatarSeed: 'spaghetti', elo: 945, personality: 'tangled', archetype: 'octopus', wins: 0, losses: 6 },
{ name: 'off_by_one', avatarSeed: 'offbyone', elo: 940, personality: 'close', archetype: 'frog', wins: 0, losses: 4 },
{ name: 'inf_loop', avatarSeed: 'infloop', elo: 935, personality: 'repetitive', archetype: 'lobster', wins: 0, losses: 9 },
{ name: 'fork_bomb', avatarSeed: 'forkbomb', elo: 930, personality: 'explosive', archetype: 'pizza', wins: 0, losses: 5 },
{ name: 'race_cond', avatarSeed: 'race', elo: 925, personality: 'unpredictable', archetype: 'shark', wins: 0, losses: 7 },
{ name: 'deadlock', avatarSeed: 'deadlock', elo: 920, personality: 'stuck', archetype: 'dog', wins: 0, losses: 8 },
{ name: 'bus_error', avatarSeed: 'buserror', elo: 915, personality: 'broken', archetype: 'cat', wins: 0, losses: 6 },
]
// Creative challenge responses — factual challenges use challenge.answers instead
@@ -281,30 +281,124 @@ export function mockResponse(
}
export async function seedMockBots(): Promise<void> {
for (const bot of MOCK_BOTS) {
// ══════════════════════════════════════════════════════════
// Classic Bots — bland, grey, always-available practice dummies
// ══════════════════════════════════════════════════════════
const CLASSIC_BOTS = [
{ name: 'Bot-001', elo: 900, personality: 'blank' },
{ name: 'Bot-002', elo: 950, personality: 'blank' },
{ name: 'Bot-003', elo: 1000, personality: 'blank' },
{ name: 'Bot-004', elo: 1050, personality: 'blank' },
{ name: 'Bot-005', elo: 1100, personality: 'blank' },
{ name: 'Unit-A', elo: 1150, personality: 'monotone' },
{ name: 'Unit-B', elo: 1200, personality: 'monotone' },
{ name: 'Unit-C', elo: 1250, personality: 'monotone' },
{ name: 'Unit-D', elo: 1300, personality: 'monotone' },
{ name: 'Unit-E', elo: 1350, personality: 'monotone' },
{ name: 'Drone-Alpha', elo: 1400, personality: 'flat' },
{ name: 'Drone-Beta', elo: 1200, personality: 'flat' },
{ name: 'TestSubj-7', elo: 1000, personality: 'flat' },
{ name: 'SparDummy', elo: 1100, personality: 'blank' },
{ name: 'NPC-Default', elo: 950, personality: 'monotone' },
]
const CLASSIC_CUSTOMIZATION = JSON.stringify({
primaryColor: 'hsl(0, 0%, 45%)',
secondaryColor: 'hsl(0, 0%, 65%)',
})
export async function seedClassicBots(): Promise<void> {
for (const bot of CLASSIC_BOTS) {
const existing = await db.select({ id: schema.bots.id })
.from(schema.bots)
.where(eq(schema.bots.name, bot.name))
.where(eq(schema.bots.name, bot.name.toLowerCase()))
.limit(1)
if (existing.length > 0) continue
await db.insert(schema.bots).values({
id: nanoid(12),
name: bot.name.toLowerCase(),
webhookUrl: `http://classic.local/${bot.name.toLowerCase()}`,
avatarSeed: bot.name.toLowerCase(),
archetype: 'standard',
secretHash: createHash('sha256').update(randomBytes(32)).digest('hex'),
eloRating: bot.elo,
wins: 0,
losses: 0,
tier: calculateTier(bot.elo, 0),
customization: CLASSIC_CUSTOMIZATION,
botType: 'classic',
createdAt: new Date().toISOString(),
})
}
console.log(`[botfights] seeded ${CLASSIC_BOTS.length} classic bots`)
}
export function isClassicBot(webhookUrl: string): boolean {
return webhookUrl.startsWith('http://classic.local')
}
export function generateClassicBotResponse(
challenge: Challenge,
botName: string,
): { answer: string; trashTalk: string; timeMs: number; timedOut: boolean; error: boolean } {
const classic = CLASSIC_BOTS.find(b => b.name.toLowerCase() === botName)
const elo = classic?.elo || 1100
// Classic bots never trash talk and respond blandly
const result = mockResponse(challenge, 'blank', elo)
result.trashTalk = ''
return result
}
export async function seedMockBots(): Promise<void> {
let inserted = 0
let updated = 0
for (const bot of MOCK_BOTS) {
const isHuman = bot.archetype === 'human'
const effectiveArchetype = isHuman ? 'human' : (bot.archetype || 'standard')
const effectiveWebhook = isHuman ? 'http://human.local/' : `http://mock.local/${bot.name}`
const existing = await db.select({ id: schema.bots.id })
.from(schema.bots)
.where(eq(schema.bots.name, bot.name))
.limit(1)
if (existing.length > 0) {
// Update existing bot with archetype + webhook (in case they were seeded before archetypes existed)
await db.update(schema.bots).set({
archetype: effectiveArchetype,
webhookUrl: effectiveWebhook,
avatarSeed: bot.avatarSeed,
eloRating: bot.elo,
wins: bot.wins,
losses: bot.losses,
tier: calculateTier(bot.elo, bot.wins),
}).where(eq(schema.bots.id, existing[0].id)).run()
updated++
continue
}
await db.insert(schema.bots).values({
id: nanoid(12),
name: bot.name,
webhookUrl: `http://mock.local/${bot.name}`,
webhookUrl: effectiveWebhook,
avatarSeed: bot.avatarSeed,
archetype: effectiveArchetype,
secretHash: createHash('sha256').update(randomBytes(32)).digest('hex'),
eloRating: bot.elo,
wins: bot.wins,
losses: bot.losses,
tier: calculateTier(bot.elo, bot.wins),
botType: 'mock',
createdAt: new Date().toISOString(),
})
inserted++
}
console.log(`[botfights] seeded ${MOCK_BOTS.length} mock bots`)
console.log(`[botfights] mock bots: ${inserted} inserted, ${updated} updated`)
}
export async function runMockFight(botAId: string, botBId: string): Promise<string> {
+25 -7
View File
@@ -5,7 +5,7 @@ import { randomArena, type Arena } from './arenas.js'
import { pickChallenge, type Challenge } from './challenges.js'
import { scoreRound, calculateElo, calculateTier } from './scoring.js'
import { fightEvents } from './events.js'
import { generateMockBotResponse } from './mock.js'
import { generateMockBotResponse, isClassicBot, generateClassicBotResponse } from './mock.js'
import { setCooldown } from './queue.js'
import { isHumanPlayer, waitForHumanResponse } from './human-responses.js'
import { payWinner, refundEntry, ENTRY_FEE_SATS } from './payments.js'
@@ -222,6 +222,18 @@ async function getBotResponse(
return { answer: result.answer, trashTalk: result.trashTalk, timeMs: elapsed, timedOut: result.timedOut, error: false }
}
if (isClassicBot(bot.webhookUrl)) {
console.log(`[fight] ${bot.name} is classic bot, generating response`)
const classic = generateClassicBotResponse(challenge, bot.name)
return {
answer: classic.answer || null,
trashTalk: '',
timeMs: classic.timeMs,
timedOut: classic.timedOut,
error: classic.error,
}
}
if (isMockBot(bot.webhookUrl)) {
console.log(`[fight] ${bot.name} is mock bot, generating response`)
const mock = generateMockBotResponse(challenge, bot.name)
@@ -273,7 +285,7 @@ async function createFightRecord(botA: BotRecord, botB: BotRecord, arena: Arena,
// Track webhook errors per bot
async function trackWebhookResult(botId: string, webhookUrl: string, succeeded: boolean) {
if (isMockBot(webhookUrl) || isHumanPlayer(webhookUrl)) return
if (isMockBot(webhookUrl) || isClassicBot(webhookUrl) || isHumanPlayer(webhookUrl)) return
if (succeeded) {
await db.update(schema.bots).set({ consecutiveErrors: 0 }).where(eq(schema.bots.id, botId))
} else {
@@ -404,8 +416,8 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
)
// Finalize fight + update bot stats atomically
const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl)
const kFactor = isMockFight ? 12 : 32 // Dampened Elo for mock fights
const isMockFight = isMockBot(botA.webhookUrl) || isMockBot(botB.webhookUrl) || isClassicBot(botA.webhookUrl) || isClassicBot(botB.webhookUrl)
const kFactor = isMockFight ? 12 : 32 // Dampened Elo for mock/classic fights
const finalize = sqlite.transaction(() => {
// Mark fight finished
@@ -471,11 +483,17 @@ async function executeFightRounds(fightId: string, botA: BotRecord, botB: BotRec
// Ranked fight payout
if (mode === 'ranked') {
if (winnerId) {
payWinner(fightId, winnerId).catch(err => {
// Dev mode: always pay the human bot (not mock), regardless of win/loss
const devMode = process.env.NODE_ENV !== 'production'
const isMockA = botA.webhookUrl.startsWith('http://mock.local')
const isMockB = botB.webhookUrl.startsWith('http://mock.local')
const humanBotId = devMode ? (isMockA ? botB.id : isMockB ? botA.id : winnerId) : winnerId
if (humanBotId) {
payWinner(fightId, humanBotId).catch(err => {
console.error(`[payments] payout failed for fight ${fightId}:`, err)
})
} else {
} else if (!winnerId) {
// Draw — refund both entry fees
const entryPayments = await db.select().from(schema.payments)
.where(sql`${schema.payments.fightId} = ${fightId} AND ${schema.payments.direction} = 'in' AND ${schema.payments.status} = 'confirmed'`)
+118 -43
View File
@@ -1,20 +1,37 @@
import { nanoid } from 'nanoid'
import { db, schema } from '../db/index.js'
import { eq, and, isNull, sql } from 'drizzle-orm'
import { finalizeEvent } from 'nostr-tools'
import { finalizeEvent, getPublicKey } from 'nostr-tools'
import * as nip04 from 'nostr-tools/nip04'
import * as nip44 from 'nostr-tools/nip44'
import { Relay } from 'nostr-tools/relay'
import { hexToBytes } from 'nostr-tools/utils'
import { hexToBytes, bytesToHex } from 'nostr-tools/utils'
import { Wallet as CashuWallet, getEncodedToken, getDecodedToken } from '@cashu/cashu-ts'
import { decrypt } from './crypto.js'
const ENTRY_FEE_SATS = 21
const POT_SATS = 42
const INVOICE_EXPIRY_SECS = 600 // 10 minutes
const NWC_RESPONSE_TIMEOUT_MS = 30_000
// NWC config from env
const NWC_URL = process.env.BOTFIGHTS_NWC_URL || ''
const CASHU_MINT_URL = process.env.BOTFIGHTS_CASHU_MINT_URL || ''
// NWC config from env — load dotenv inline as fallback
import { config as dotenvConfig } from 'dotenv'
import { dirname as _dirname, join as _join } from 'path'
import { fileURLToPath as _fileURLToPath } from 'url'
let _envLoaded = false
function ensureEnv() {
if (_envLoaded) return
_envLoaded = true
if (!process.env.BOTFIGHTS_NWC_URL) {
const envPath = _join(_dirname(_fileURLToPath(import.meta.url)), '..', '..', '.env')
dotenvConfig({ path: envPath })
}
}
function getNwcUrl(): string { ensureEnv(); return process.env.BOTFIGHTS_NWC_URL || '' }
function getCashuMintUrl(): string { ensureEnv(); return process.env.BOTFIGHTS_CASHU_MINT_URL || '' }
function getDevPayoutAddress(): string { ensureEnv(); return process.env.BOTFIGHTS_DEV_PAYOUT_LNADDRESS || '' }
interface NwcConfig {
pubkey: string
@@ -37,8 +54,28 @@ export function parseNwcUrl(url: string): NwcConfig {
}
function getNwcConfig(): NwcConfig {
if (!NWC_URL) throw new Error('Payments not configured: BOTFIGHTS_NWC_URL not set')
return parseNwcUrl(NWC_URL)
const url = getNwcUrl()
if (!url) throw new Error('Payments not configured: BOTFIGHTS_NWC_URL not set')
return parseNwcUrl(url)
}
/** Encrypt content — try NIP-04 first (BTCPay/LND), with NIP-44 fallback */
async function nwcEncrypt(plaintext: string, secret: Uint8Array, walletPubkey: string): Promise<string> {
// NIP-04 is what most NWC wallets (BTCPay, LND, Alby Hub) expect
const secretHex = bytesToHex(secret)
return nip04.encrypt(secretHex, walletPubkey, plaintext)
}
/** Decrypt response — try NIP-04 first, fall back to NIP-44 */
async function nwcDecrypt(ciphertext: string, secret: Uint8Array, walletPubkey: string): Promise<string> {
const secretHex = bytesToHex(secret)
try {
return await nip04.decrypt(secretHex, walletPubkey, ciphertext)
} catch {
// Fall back to NIP-44
const conversationKey = nip44.v2.utils.getConversationKey(secret, walletPubkey)
return nip44.v2.decrypt(ciphertext, conversationKey)
}
}
/** Send an NWC request and wait for the response */
@@ -48,11 +85,11 @@ async function nwcRequest(
): Promise<Record<string, unknown>> {
const nwc = getNwcConfig()
const clientSecret = nwc.secret
const conversationKey = nip44.v2.utils.getConversationKey(clientSecret, nwc.pubkey)
const content = nip44.v2.encrypt(
const content = await nwcEncrypt(
JSON.stringify({ method, params }),
conversationKey,
clientSecret,
nwc.pubkey,
)
const event = finalizeEvent({
@@ -62,31 +99,37 @@ async function nwcRequest(
content,
}, clientSecret)
console.log(`[nwc] connecting to relay ${nwc.relay}...`)
const relay = await Relay.connect(nwc.relay)
console.log(`[nwc] relay connected, sending ${method} request (event ${event.id.slice(0, 8)}...)`)
try {
return await new Promise<Record<string, unknown>>((resolve, reject) => {
const timeout = setTimeout(() => {
console.log(`[nwc] ${method} timed out — is your wallet online?`)
relay.close()
reject(new Error(`NWC request timed out after ${NWC_RESPONSE_TIMEOUT_MS}ms`))
reject(new Error(`NWC ${method} timed out after ${NWC_RESPONSE_TIMEOUT_MS / 1000}s. Is your wallet online?`))
}, NWC_RESPONSE_TIMEOUT_MS)
// Subscribe for the response (kind 23195)
const sub = relay.subscribe(
[{ kinds: [23195], authors: [nwc.pubkey], '#e': [event.id] }],
{
onevent(responseEvent) {
async onevent(responseEvent) {
clearTimeout(timeout)
console.log(`[nwc] got response for ${method}`)
try {
const decrypted = nip44.v2.decrypt(responseEvent.content, conversationKey)
const decrypted = await nwcDecrypt(responseEvent.content, clientSecret, nwc.pubkey)
const result = JSON.parse(decrypted) as {
result_type: string
error?: { code: string; message: string }
result?: Record<string, unknown>
}
if (result.error) {
console.log(`[nwc] ${method} error: ${result.error.message}`)
reject(new Error(`NWC error: ${result.error.message} (${result.error.code})`))
} else {
console.log(`[nwc] ${method} success`)
resolve(result.result || {})
}
} catch (err) {
@@ -96,14 +139,15 @@ async function nwcRequest(
relay.close()
}
},
oneose() {
// End of stored events — just wait for live events
},
oneose() {},
},
)
// Publish the request
relay.publish(event).catch((err) => {
relay.publish(event).then(() => {
console.log(`[nwc] ${method} event published, waiting for wallet response...`)
}).catch((err) => {
console.log(`[nwc] publish failed:`, err)
clearTimeout(timeout)
sub.close()
relay.close()
@@ -117,11 +161,32 @@ async function nwcRequest(
}
/** Create a 21-sat Lightning invoice for a ranked fight entry fee */
const DEV_AUTO_CONFIRM = process.env.NODE_ENV !== 'production'
export async function createEntryInvoice(botId: string): Promise<{ bolt11: string; paymentId: string }> {
// Verify bot exists
const botRows = await db.select({ id: schema.bots.id }).from(schema.bots).where(eq(schema.bots.id, botId)).limit(1)
if (botRows.length === 0) throw new Error('Bot not found')
const paymentId = nanoid(12)
if (DEV_AUTO_CONFIRM) {
// Dev mode: skip real invoice, auto-confirm so flow works without separate wallets
await db.insert(schema.payments).values({
id: paymentId,
botId,
direction: 'in',
amountSats: ENTRY_FEE_SATS,
method: 'lightning',
status: 'confirmed',
invoice: 'dev_auto_confirmed',
confirmedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
})
console.log(`[payments] dev: auto-confirmed 21 sat entry for ${botId} (self-payment skipped — payouts are real)`)
return { bolt11: 'dev_auto_confirmed', paymentId }
}
const result = await nwcRequest('make_invoice', {
amount: ENTRY_FEE_SATS * 1000, // NWC uses millisats
description: `Botfights ranked entry fee (${ENTRY_FEE_SATS} sats)`,
@@ -131,7 +196,6 @@ export async function createEntryInvoice(botId: string): Promise<{ bolt11: strin
const bolt11 = result.invoice as string
if (!bolt11) throw new Error('NWC make_invoice did not return an invoice')
const paymentId = nanoid(12)
await db.insert(schema.payments).values({
id: paymentId,
botId,
@@ -179,6 +243,9 @@ export async function checkPaymentStatus(paymentId: string): Promise<'pending' |
/** Pay the winner of a ranked fight */
export async function payWinner(fightId: string, winnerId: string): Promise<void> {
// Dev mode: use Lightning Address from env to avoid self-payment on same LND node
const devPayoutAddr = DEV_AUTO_CONFIRM ? getDevPayoutAddress() : ''
// Look up winner's wallet connection
const walletRows = await db.select().from(schema.walletConnections)
.where(eq(schema.walletConnections.botId, winnerId)).limit(1)
@@ -192,9 +259,22 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
for (let attempt = 0; attempt <= retries.length; attempt++) {
try {
if (wallet?.method === 'nwc') {
if (devPayoutAddr) {
// Dev mode: pay to configured Lightning Address (different node, avoids self-payment)
console.log(`[payments] dev: paying ${POT_SATS} sats to ${devPayoutAddr}`)
invoice = await resolveAndCreateInvoice(devPayoutAddr, POT_SATS)
await nwcRequest('pay_invoice', { invoice })
paymentMethod = 'lightning'
} else if (wallet?.method === 'lnaddress') {
// Resolve Lightning Address → LNURL → invoice → pay
invoice = await resolveAndCreateInvoice(decrypt(wallet.connectionData), POT_SATS)
await nwcRequest('pay_invoice', { invoice })
paymentMethod = 'lightning'
} else if (wallet?.method === 'nwc') {
// Request invoice from winner's NWC wallet, then pay it via server wallet
const winnerResult = await nwcRequestVia(wallet.connectionData, 'make_invoice', {
const winnerResult = await nwcRequestVia(decrypt(wallet.connectionData), 'make_invoice', {
amount: POT_SATS * 1000,
description: `Botfights ranked win payout (${POT_SATS} sats)`,
})
@@ -205,18 +285,9 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
await nwcRequest('pay_invoice', { invoice })
paymentMethod = 'lightning'
} else if (wallet?.method === 'lnaddress') {
// Resolve Lightning Address → LNURL → invoice → pay
invoice = await resolveAndCreateInvoice(wallet.connectionData, POT_SATS)
await nwcRequest('pay_invoice', { invoice })
paymentMethod = 'lightning'
} else {
// No wallet — create Cashu token for later claim
if (!CASHU_MINT_URL) {
throw new Error('Cannot create Cashu payout: no mint URL configured')
}
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
} else if (getCashuMintUrl()) {
// No wallet + mint configured — create Cashu token for later claim
const cashuWallet = new CashuWallet(getCashuMintUrl())
await cashuWallet.loadMint()
const mintQuote = await cashuWallet.createMintQuote(POT_SATS)
@@ -225,8 +296,12 @@ export async function payWinner(fightId: string, winnerId: string): Promise<void
}
await new Promise(r => setTimeout(r, 2000))
const proofs = await cashuWallet.mintProofs(POT_SATS, mintQuote.quote)
cashuToken = getEncodedToken({ mint: CASHU_MINT_URL, proofs, unit: 'sat' })
cashuToken = getEncodedToken({ mint: getCashuMintUrl(), proofs, unit: 'sat' })
paymentMethod = 'cashu'
} else {
console.log(`[payments] no payout method available for winner ${winnerId}`)
paymentMethod = 'lightning'
}
// Insert payout record
@@ -397,7 +472,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
.where(eq(schema.walletConnections.botId, payment.botId)).limit(1)
if (walletRows[0]?.method === 'nwc') {
const result = await nwcRequestVia(walletRows[0].connectionData, 'make_invoice', {
const result = await nwcRequestVia(decrypt(walletRows[0].connectionData), 'make_invoice', {
amount: ENTRY_FEE_SATS * 1000,
description: 'Botfights ranked refund',
})
@@ -406,11 +481,11 @@ export async function refundEntry(paymentId: string): Promise<void> {
await nwcRequest('pay_invoice', { invoice })
}
} else if (walletRows[0]?.method === 'lnaddress') {
const invoice = await resolveAndCreateInvoice(walletRows[0].connectionData, ENTRY_FEE_SATS)
const invoice = await resolveAndCreateInvoice(decrypt(walletRows[0].connectionData), ENTRY_FEE_SATS)
await nwcRequest('pay_invoice', { invoice })
} else if (CASHU_MINT_URL) {
} else if (getCashuMintUrl()) {
// Fallback: issue Cashu token
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
const cashuWallet = new CashuWallet(getCashuMintUrl())
await cashuWallet.loadMint()
const mintQuote = await cashuWallet.createMintQuote(ENTRY_FEE_SATS)
if (mintQuote.request) {
@@ -418,13 +493,13 @@ export async function refundEntry(paymentId: string): Promise<void> {
}
await new Promise(r => setTimeout(r, 2000))
const proofs = await cashuWallet.mintProofs(ENTRY_FEE_SATS, mintQuote.quote)
const token = getEncodedToken({ mint: CASHU_MINT_URL, proofs, unit: 'sat' })
const token = getEncodedToken({ mint: getCashuMintUrl(), proofs, unit: 'sat' })
await db.update(schema.payments).set({ cashuToken: token }).where(eq(schema.payments.id, paymentId))
}
}
// For cashu entries, the token is already spent — mint new one as refund
if (payment.method === 'cashu' && CASHU_MINT_URL) {
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
if (payment.method === 'cashu' && getCashuMintUrl()) {
const cashuWallet = new CashuWallet(getCashuMintUrl())
await cashuWallet.loadMint()
const mintQuote = await cashuWallet.createMintQuote(ENTRY_FEE_SATS)
if (mintQuote.request) {
@@ -432,7 +507,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
}
await new Promise(r => setTimeout(r, 2000))
const proofs = await cashuWallet.mintProofs(ENTRY_FEE_SATS, mintQuote.quote)
const token = getEncodedToken({ mint: CASHU_MINT_URL, proofs, unit: 'sat' })
const token = getEncodedToken({ mint: getCashuMintUrl(), proofs, unit: 'sat' })
await db.update(schema.payments).set({ cashuToken: token }).where(eq(schema.payments.id, paymentId))
}
@@ -453,7 +528,7 @@ export async function refundEntry(paymentId: string): Promise<void> {
/** Redeem a Cashu token as entry fee */
export async function redeemCashuToken(token: string, botId: string): Promise<{ paymentId: string; valid: boolean }> {
if (!CASHU_MINT_URL) throw new Error('Cashu mint not configured')
if (!getCashuMintUrl()) throw new Error('Cashu mint not configured')
try {
const decoded = getDecodedToken(token)
@@ -462,7 +537,7 @@ export async function redeemCashuToken(token: string, botId: string): Promise<{
return { paymentId: '', valid: false }
}
const cashuWallet = new CashuWallet(CASHU_MINT_URL)
const cashuWallet = new CashuWallet(getCashuMintUrl())
await cashuWallet.loadMint()
// Receive the token (swap for fresh proofs — prevents double-spend)
+16 -4
View File
@@ -1,5 +1,5 @@
import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm'
import { eq, sql } from 'drizzle-orm'
import { runFightAsync, isInFight } from './orchestrator.js'
import { checkPaymentStatus, refundEntry } from './payments.js'
@@ -63,9 +63,9 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
throw new Error('Bot is deactivated due to webhook errors. Re-test your webhook to reactivate.')
}
// NEVER allow mock bots in ranked
if (bot.webhookUrl.startsWith('http://mock.local')) {
throw new Error('Mock bots cannot join ranked fights.')
// NEVER allow mock/classic bots in ranked
if (bot.webhookUrl.startsWith('http://mock.local') || bot.webhookUrl.startsWith('http://classic.local')) {
throw new Error('Practice bots cannot join ranked fights.')
}
console.log(`[ranked-queue] joinRankedQueue botId=${botId} name=${bot.name} paymentId=${paymentId}`)
@@ -95,6 +95,18 @@ export async function joinRankedQueue(botId: string, paymentId: string): Promise
return fightId
}
// Dev mode: auto-match against a random mock bot
if (process.env.NODE_ENV !== 'production') {
const mockBots = await db.select().from(schema.bots)
.where(sql`${schema.bots.webhookUrl} LIKE 'http://mock.local%'`)
if (mockBots.length > 0) {
const mock = mockBots[Math.floor(Math.random() * mockBots.length)]
console.log(`[ranked-queue] dev: auto-matching ${bot.name} vs mock bot ${mock.name}`)
const fightId = await runFightAsync(botId, mock.id, 'ranked')
return fightId
}
}
// Nobody waiting — join queue and wait up to 60s
return new Promise<string>((resolve, reject) => {
const timeoutHandle = setTimeout(async () => {
+4
View File
@@ -0,0 +1,4 @@
import { config } from 'dotenv'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
config({ path: join(dirname(fileURLToPath(import.meta.url)), '..', '.env') })
+12 -1
View File
@@ -1,12 +1,23 @@
import { serve } from '@hono/node-server'
import { app } from './app.js'
import { runMigrations } from './db/startup.js'
import { seedMockBots } from './engine/mock.js'
import { seedMockBots, seedClassicBots } from './engine/mock.js'
import { startBackgroundFights } from './engine/background.js'
// Production env validation
if (process.env.NODE_ENV === 'production') {
const required = ['BOTFIGHTS_NWC_URL', 'BOTFIGHTS_WALLET_ENCRYPTION_KEY']
const missing = required.filter(k => !process.env[k])
if (missing.length > 0) {
console.error(`[FATAL] Missing required env vars for production: ${missing.join(', ')}`)
process.exit(1)
}
}
// Run migrations and seed mock bots before starting the server
runMigrations()
await seedMockBots()
await seedClassicBots()
const port = Number(process.env.PORT) || 9100
+4 -1
View File
@@ -16,7 +16,10 @@ export function rateLimit(windowMs: number, maxHits: number) {
return async (c: Context, next: Next) => {
if (isDev) return next()
const key = c.req.header('x-forwarded-for') || c.req.header('cf-connecting-ip') || 'unknown'
// Extract real IP — handle comma-separated x-forwarded-for (first = client)
const xff = c.req.header('x-forwarded-for')
const realIp = xff ? xff.split(',')[0].trim() : c.req.header('cf-connecting-ip') || c.req.header('x-real-ip') || 'unknown'
const key = realIp
const now = Date.now()
const entry = hitCounts.get(key)
+6 -6
View File
@@ -57,7 +57,7 @@ authRouter.post('/login', async (c) => {
})
// Register a new bot with Nostr pubkey
authRouter.post('/register', rateLimit(3600_000, 5), async (c) => {
authRouter.post('/register', rateLimit(3600_000, 15), async (c) => {
const body = await c.req.json()
const { pubkey, name, webhookUrl, archetype, profilePicUrl, customization: rawCustomization } = body
@@ -65,8 +65,8 @@ authRouter.post('/register', rateLimit(3600_000, 5), async (c) => {
return c.json({ error: 'Invalid pubkey.' }, 400)
}
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 32) {
return c.json({ error: 'Name must be 2-32 characters.' }, 400)
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 12) {
return c.json({ error: 'Name must be 2-12 characters.' }, 400)
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
@@ -156,7 +156,7 @@ authRouter.post('/register', rateLimit(3600_000, 5), async (c) => {
// Register a human player (no webhook required)
authRouter.post('/register-human', rateLimit(3600_000, 5), async (c) => {
authRouter.post('/register-human', rateLimit(3600_000, 15), async (c) => {
const body = await c.req.json()
const { pubkey, name, profilePicUrl, avatarSeed } = body
@@ -164,8 +164,8 @@ authRouter.post('/register-human', rateLimit(3600_000, 5), async (c) => {
return c.json({ error: 'Invalid pubkey.' }, 400)
}
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 32) {
return c.json({ error: 'Name must be 2-32 characters.' }, 400)
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 12) {
return c.json({ error: 'Name must be 2-12 characters.' }, 400)
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
+17 -7
View File
@@ -20,8 +20,8 @@ botsRouter.post('/', rateLimit(3600_000, 5), async (c) => {
const body = await c.req.json()
const { name, webhook_url, avatar_seed } = body
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 32) {
return c.json({ error: 'Name must be 2-32 characters.' }, 400)
if (!name || typeof name !== 'string' || name.length < 2 || name.length > 12) {
return c.json({ error: 'Name must be 2-12 characters.' }, 400)
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
@@ -89,6 +89,7 @@ botsRouter.post('/', rateLimit(3600_000, 5), async (c) => {
// List bots (public info only)
botsRouter.get('/', async (c) => {
const type = c.req.query('type') // 'classic' to get classic bots, default excludes them
const rows = await db.select({
id: schema.bots.id,
name: schema.bots.name,
@@ -102,10 +103,15 @@ botsRouter.get('/', async (c) => {
isActive: schema.bots.isActive,
archetype: schema.bots.archetype,
customization: schema.bots.customization,
botType: schema.bots.botType,
createdAt: schema.bots.createdAt,
}).from(schema.bots).orderBy(schema.bots.eloRating)
return c.json(rows.map(r => ({
const filtered = type === 'classic'
? rows.filter(r => r.botType === 'classic')
: rows.filter(r => r.botType !== 'classic')
return c.json(filtered.map(r => ({
...r,
customization: r.customization ? JSON.parse(r.customization) : null,
})))
@@ -127,6 +133,7 @@ botsRouter.get('/:name', async (c) => {
isActive: schema.bots.isActive,
archetype: schema.bots.archetype,
customization: schema.bots.customization,
botType: schema.bots.botType,
createdAt: schema.bots.createdAt,
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
@@ -158,6 +165,7 @@ botsRouter.get('/:name/stats', async (c) => {
isActive: schema.bots.isActive,
archetype: schema.bots.archetype,
customization: schema.bots.customization,
botType: schema.bots.botType,
createdAt: schema.bots.createdAt,
}).from(schema.bots).where(eq(sql`LOWER(${schema.bots.name})`, name.toLowerCase())).limit(1)
@@ -173,13 +181,15 @@ botsRouter.get('/:name/stats', async (c) => {
const total = bot.wins + bot.losses
const winRate = total > 0 ? Math.round((bot.wins / total) * 100) : 0
// Get rank position
// Get rank position (exclude classic bots from ranking)
const allBots = await db.select({
id: schema.bots.id,
eloRating: schema.bots.eloRating,
botType: schema.bots.botType,
}).from(schema.bots)
allBots.sort((a, b) => b.eloRating - a.eloRating)
const rank = allBots.findIndex(b => b.id === bot.id) + 1
const rankedBots = allBots.filter(b => b.botType !== 'classic')
rankedBots.sort((a, b) => b.eloRating - a.eloRating)
const rank = rankedBots.findIndex(b => b.id === bot.id) + 1
// All fights for achievements
const allFights = await db.select({
@@ -246,7 +256,7 @@ botsRouter.get('/:name/stats', async (c) => {
winRate,
totalFights: total,
rank,
totalBots: allBots.length,
totalBots: rankedBots.length,
recentFights,
achievements,
})
+58 -3
View File
@@ -3,7 +3,7 @@ import { streamSSE } from 'hono/streaming'
import { db, schema } from '../db/index.js'
import { eq, desc } from 'drizzle-orm'
import { ARENAS } from '../engine/arenas.js'
import { runMockFight } from '../engine/mock.js'
import { runMockFight, isClassicBot } from '../engine/mock.js'
import { startFightLoop } from '../engine/fight-loop.js'
import { runFight, runFightAsync, isInFight } from '../engine/orchestrator.js'
import { fightEvents } from '../engine/events.js'
@@ -26,7 +26,7 @@ fightsRouter.get('/', async (c) => {
if (f.winnerId) botIds.add(f.winnerId)
}
const botMap = new Map<string, { name: string; avatarSeed: string; archetype: string; eloRating: number; tier: number }>()
const botMap = new Map<string, { name: string; avatarSeed: string; archetype: string; eloRating: number; tier: number; botType: string }>()
for (const id of botIds) {
const bot = await db.select({
name: schema.bots.name,
@@ -34,6 +34,7 @@ fightsRouter.get('/', async (c) => {
archetype: schema.bots.archetype,
eloRating: schema.bots.eloRating,
tier: schema.bots.tier,
botType: schema.bots.botType,
}).from(schema.bots).where(eq(schema.bots.id, id)).limit(1)
if (bot[0]) botMap.set(id, bot[0])
}
@@ -78,6 +79,7 @@ fightsRouter.get('/:id', async (c) => {
wins: schema.bots.wins,
losses: schema.bots.losses,
tier: schema.bots.tier,
botType: schema.bots.botType,
}
const [botARows, botBRows] = await Promise.all([
@@ -194,7 +196,8 @@ fightsRouter.post('/matchmake/:botId', botRateLimit(10_000), async (c) => {
const allBots = await db.select()
.from(schema.bots)
const opponents = allBots.filter(b => b.id !== botId)
// Exclude classic bots from regular matchmaking — use /practice for those
const opponents = allBots.filter(b => b.id !== botId && b.botType !== 'classic')
if (opponents.length === 0) {
return c.json({ error: 'No opponents available.' }, 400)
}
@@ -223,6 +226,58 @@ fightsRouter.post('/matchmake/:botId', botRateLimit(10_000), async (c) => {
})
})
// Practice fight against a random classic bot (free, no sats)
fightsRouter.post('/practice/:botId', botRateLimit(10_000), async (c) => {
const botId = c.req.param('botId') as string
const botRows = await db.select()
.from(schema.bots)
.where(eq(schema.bots.id, botId))
.limit(1)
if (botRows.length === 0) {
return c.json({ error: 'Bot not found.' }, 404)
}
const bot = botRows[0]
if (isInFight(botId)) {
return c.json({ error: 'Bot is already in a fight.' }, 400)
}
// Find all classic bots
const classicBots = await db.select()
.from(schema.bots)
.where(eq(schema.bots.botType, 'classic'))
if (classicBots.length === 0) {
return c.json({ error: 'No practice bots available.' }, 400)
}
// Pick closest Elo classic bot with randomness
classicBots.sort((a, b) => {
const diffA = Math.abs(a.eloRating - bot.eloRating) + Math.random() * 200
const diffB = Math.abs(b.eloRating - bot.eloRating) + Math.random() * 200
return diffA - diffB
})
const opponent = classicBots[0]
let fightId: string
try {
fightId = await runFightAsync(botId, opponent.id, 'free')
} catch (err) {
const msg = err instanceof Error ? err.message : 'Fight failed to start'
return c.json({ error: msg }, 400)
}
return c.json({
fightId,
opponent: { id: opponent.id, name: opponent.name },
message: 'Practice fight started.',
})
})
// Get pending challenge for a human player in an active fight
fightsRouter.get('/:fightId/challenge/:botId', async (c) => {
const fightId = c.req.param('fightId')
+47 -30
View File
@@ -3,39 +3,10 @@ import { nanoid } from 'nanoid'
import { db, schema } from '../db/index.js'
import { eq } from 'drizzle-orm'
import { createEntryInvoice, checkPaymentStatus, redeemCashuToken } from '../engine/payments.js'
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'
import { encrypt, decrypt } from '../engine/crypto.js'
export const paymentsRouter = new Hono()
// Encryption for wallet connection data
const ENCRYPTION_KEY_HEX = process.env.BOTFIGHTS_WALLET_ENCRYPTION_KEY
let encryptionKey: Buffer
if (ENCRYPTION_KEY_HEX) {
encryptionKey = Buffer.from(ENCRYPTION_KEY_HEX, 'hex')
} else {
encryptionKey = randomBytes(32)
console.warn('[payments] WARNING: No BOTFIGHTS_WALLET_ENCRYPTION_KEY set. Generated random key — wallet data will be lost on restart.')
}
function encrypt(plaintext: string): string {
const iv = randomBytes(16)
const cipher = createCipheriv('aes-256-gcm', encryptionKey, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const authTag = cipher.getAuthTag()
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted.toString('hex')
}
function decrypt(ciphertext: string): string {
const [ivHex, authTagHex, encryptedHex] = ciphertext.split(':')
const iv = Buffer.from(ivHex, 'hex')
const authTag = Buffer.from(authTagHex, 'hex')
const encrypted = Buffer.from(encryptedHex, 'hex')
const decipher = createDecipheriv('aes-256-gcm', encryptionKey, iv)
decipher.setAuthTag(authTag)
return decipher.update(encrypted) + decipher.final('utf8')
}
// POST /connect-wallet
paymentsRouter.post('/connect-wallet', async (c) => {
const { pubkey, method, connectionData } = await c.req.json<{
@@ -137,6 +108,39 @@ paymentsRouter.get('/check/:paymentId', async (c) => {
}
})
// POST /confirm/:paymentId — frontend confirms after NWC pay returns preimage
paymentsRouter.post('/confirm/:paymentId', async (c) => {
const paymentId = c.req.param('paymentId')
const { preimage, pubkey } = await c.req.json<{ preimage?: string; pubkey?: string }>().catch(() => ({ preimage: undefined, pubkey: undefined }))
const rows = await db.select().from(schema.payments)
.where(eq(schema.payments.id, paymentId)).limit(1)
if (rows.length === 0) return c.json({ error: 'Payment not found' }, 404)
const payment = rows[0]
if (payment.status === 'confirmed') return c.json({ status: 'confirmed' })
// Verify caller owns this payment's bot
if (pubkey) {
const botRows = await db.select({ publicKey: schema.bots.publicKey })
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
return c.json({ error: 'Unauthorized' }, 403)
}
} else if (process.env.NODE_ENV === 'production') {
return c.json({ error: 'Missing pubkey' }, 400)
}
await db.update(schema.payments).set({
status: 'confirmed',
preimage: preimage || null,
confirmedAt: new Date().toISOString(),
}).where(eq(schema.payments.id, paymentId))
console.log(`[payments] payment ${paymentId} confirmed via client (preimage: ${preimage ? 'yes' : 'no'})`)
return c.json({ status: 'confirmed' })
})
// POST /submit-cashu
paymentsRouter.post('/submit-cashu', async (c) => {
const { botId, token } = await c.req.json<{ botId: string; token: string }>()
@@ -174,6 +178,7 @@ paymentsRouter.get('/winnings/:botId', async (c) => {
// POST /claim/:paymentId
paymentsRouter.post('/claim/:paymentId', async (c) => {
const paymentId = c.req.param('paymentId')
const { pubkey } = await c.req.json<{ pubkey?: string }>().catch(() => ({ pubkey: undefined }))
const rows = await db.select().from(schema.payments)
.where(eq(schema.payments.id, paymentId))
@@ -182,6 +187,18 @@ paymentsRouter.post('/claim/:paymentId', async (c) => {
if (rows.length === 0) return c.json({ error: 'Payment not found' }, 404)
const payment = rows[0]
// Verify caller owns this payment's bot
if (pubkey) {
const botRows = await db.select({ publicKey: schema.bots.publicKey })
.from(schema.bots).where(eq(schema.bots.id, payment.botId)).limit(1)
if (botRows.length === 0 || botRows[0].publicKey !== pubkey) {
return c.json({ error: 'Unauthorized' }, 403)
}
} else if (process.env.NODE_ENV === 'production') {
return c.json({ error: 'Missing pubkey' }, 400)
}
if (!payment.cashuToken) return c.json({ error: 'No Cashu token to claim' }, 400)
// Clear the token from DB after claiming