dubs-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/.claude/settings.local.json +280 -0
  2. package/CLAUDE.md +46 -0
  3. package/CONNECT4_PRODUCTION_DEPLOY.md +155 -0
  4. package/CURRENT_SESSION.md +171 -0
  5. package/CURRENT_SESSION_DRAW.md +516 -0
  6. package/MARCH_MADNESS_SURVIVOR.md +254 -0
  7. package/PANDA.md +166 -0
  8. package/Procfile +4 -0
  9. package/README.md +476 -0
  10. package/controllers/livescoresController.js +376 -0
  11. package/controllers/pickemController.js +554 -0
  12. package/controllers/survivorAdminController.js +887 -0
  13. package/controllers/survivorController.js +623 -0
  14. package/cron/oracleMonitor.js +77 -0
  15. package/cron/pickemOracleMonitor.js +73 -0
  16. package/data/jackpot-history.json +952 -0
  17. package/data/ncaaTeams.js +406 -0
  18. package/documentation/API_SECURITY_GUIDE.md +327 -0
  19. package/documentation/ARCADE_API.md +593 -0
  20. package/documentation/ARCADE_IMPLEMENTATION_SUMMARY.md +399 -0
  21. package/documentation/ARCADE_QUICKSTART.md +242 -0
  22. package/documentation/AUTOMATIC_MODE_ORACLE.md +321 -0
  23. package/documentation/BUG_FIX_COHORT_DATE_DISPLAY.md +171 -0
  24. package/documentation/CLAIM_MIGRATION_INSTRUCTIONS.md +52 -0
  25. package/documentation/CLAIM_STATUS_FIX.md +67 -0
  26. package/documentation/CLI_TOOL_GUIDE.md +372 -0
  27. package/documentation/COHORT_RETENTION_ANALYSIS.md +295 -0
  28. package/documentation/COHORT_RETENTION_IMPLEMENTATION_COMPLETE.md +461 -0
  29. package/documentation/COHORT_RETENTION_SUMMARY.md +204 -0
  30. package/documentation/COMPLETE_PROJECT_SUMMARY.md +490 -0
  31. package/documentation/DATABASE_QUERIES.md +269 -0
  32. package/documentation/DATABASE_RETENTION_POLICY.md +390 -0
  33. package/documentation/DATABASE_SETUP_GUIDE.md +361 -0
  34. package/documentation/DATABASE_SETUP_SUMMARY.md +247 -0
  35. package/documentation/DEMO_API_CURL_COMMANDS.md +656 -0
  36. package/documentation/DEPLOYMENT_SUMMARY.txt +100 -0
  37. package/documentation/DUPLICATE_NOTIFICATIONS_FIXED.md +201 -0
  38. package/documentation/EXCHANGE_RATES_INTEGRATION.md +371 -0
  39. package/documentation/FINAL_API_PROTECTION_TABLE.md +175 -0
  40. package/documentation/GAME_START_NOTIFICATIONS_DEPLOYMENT.md +256 -0
  41. package/documentation/GAME_START_NOTIFICATIONS_INTEGRATION.md +275 -0
  42. package/documentation/HEROKU_DEPLOYMENT.md +134 -0
  43. package/documentation/HEROKU_SCHEDULER_SETUP.md +271 -0
  44. package/documentation/JACKPOT_API.md +521 -0
  45. package/documentation/JACKPOT_DEPLOYMENT_GUIDE.md +362 -0
  46. package/documentation/JWT_IMPLEMENTATION_SUMMARY.md +373 -0
  47. package/documentation/JWT_QUICK_SETUP.md +268 -0
  48. package/documentation/JWT_TESTING_GUIDE.md +404 -0
  49. package/documentation/KEEPER_RECOVERY_GUIDE.md +381 -0
  50. package/documentation/KEEPER_SETUP.md +206 -0
  51. package/documentation/KEEPER_STATE_MACHINE.md +423 -0
  52. package/documentation/LATEST_PRODUCTION_SETUP.md +387 -0
  53. package/documentation/LOCAL_VOTING_TEST.md +279 -0
  54. package/documentation/ORACLE_FIXES_SUMMARY.md +188 -0
  55. package/documentation/ORACLE_POSTGRESQL_UPDATE.md +202 -0
  56. package/documentation/PAYMENT_DEPLOYMENT.md +209 -0
  57. package/documentation/PNL_TRACKING_SETUP.md +189 -0
  58. package/documentation/PREVENTING_LOCKUP_ERRORS.md +472 -0
  59. package/documentation/PRODUCTION_READY_SUMMARY.md +227 -0
  60. package/documentation/PUBLIC_VS_PRIVATE_ENDPOINTS.md +278 -0
  61. package/documentation/QUICK_AUTH_SETUP.md +99 -0
  62. package/documentation/QUICK_DEPLOY.md +224 -0
  63. package/documentation/QUICK_FIX.md +114 -0
  64. package/documentation/QUICK_START.md +152 -0
  65. package/documentation/REFEREE_MODE_GUIDE.md +392 -0
  66. package/documentation/RETENTION_CORE_ACTION_UPDATE.md +313 -0
  67. package/documentation/RETENTION_UPDATE_SUMMARY.md +108 -0
  68. package/documentation/RUN_MIGRATION_NOW.md +39 -0
  69. package/documentation/SCRIPTS_UPDATE_SUMMARY.md +251 -0
  70. package/documentation/SETUP_GUIDE.md +184 -0
  71. package/documentation/STATE_MACHINE_IMPLEMENTATION.md +250 -0
  72. package/documentation/TELEGRAM_NOTIFICATIONS_DIAGNOSIS.md +361 -0
  73. package/documentation/UNIFIED_ARCHITECTURE.md +231 -0
  74. package/documentation/VOTING_DEPLOYMENT_SUMMARY.md +392 -0
  75. package/documentation/WEBSOCKET_ARCHITECTURE.md +881 -0
  76. package/documentation/WHAT_WE_BUILT_TODAY.md +369 -0
  77. package/documentation/latest/LATEST_PRODUCTION_SETUP.md +865 -0
  78. package/ecosystem.config.js +65 -0
  79. package/env.template +125 -0
  80. package/middleware/apiKeyAuth.js +136 -0
  81. package/middleware/authenticate.js +214 -0
  82. package/middleware/developerUserAuth.js +76 -0
  83. package/middleware/socketAuth.js +69 -0
  84. package/package.json +49 -0
  85. package/postman/Dubs-API-v1-With-Voting.postman_collection.json +555 -0
  86. package/postman/Dubs-API-v1.postman_collection.json +205 -0
  87. package/postman/Dubs_Developer_API.postman_collection.json +662 -0
  88. package/postman/QUICKSTART.md +118 -0
  89. package/postman/QUICK_REFERENCE.md +246 -0
  90. package/postman/README.md +71 -0
  91. package/postman/VOTING_API_GUIDE.md +426 -0
  92. package/refactor/Animations.md +148 -0
  93. package/refactor/Chat.md +252 -0
  94. package/routes/actionsRoutes.js +699 -0
  95. package/routes/adminRoutes.js +370 -0
  96. package/routes/analyticsRoutes.js +1262 -0
  97. package/routes/arcadeRoutes.js +557 -0
  98. package/routes/authRoutes.js +2310 -0
  99. package/routes/avatarRoutes.js +85 -0
  100. package/routes/botRoutes.js +211 -0
  101. package/routes/chatRoutes.js +377 -0
  102. package/routes/cryptoPriceRoutes.js +105 -0
  103. package/routes/developerRoutes.js +4201 -0
  104. package/routes/deviceRoutes.js +214 -0
  105. package/routes/dmRoutes.js +167 -0
  106. package/routes/esportsRoutes.js +806 -0
  107. package/routes/exchangeRateRoutes.js +233 -0
  108. package/routes/gamesRoutes.js +3028 -0
  109. package/routes/jackpotRoutes.js +754 -0
  110. package/routes/keeperMonitoringRoutes.js +156 -0
  111. package/routes/keeperWebhookRoutes.js +466 -0
  112. package/routes/livescoresRoutes.js +31 -0
  113. package/routes/pickemAdminRoutes.js +199 -0
  114. package/routes/pickemRoutes.js +231 -0
  115. package/routes/playerStatsRoutes.js +147 -0
  116. package/routes/portfolioRoutes.js +217 -0
  117. package/routes/promoRoutes.js +418 -0
  118. package/routes/referralEarningsRoutes.js +392 -0
  119. package/routes/socialRoutes.js +459 -0
  120. package/routes/sportsRoutes.js +1271 -0
  121. package/routes/survivorAdminRoutes.js +345 -0
  122. package/routes/survivorRoutes.js +756 -0
  123. package/routes/uploadRoutes.js +256 -0
  124. package/routes/userProfileRoutes.js +244 -0
  125. package/routes/whatsNewRoutes.js +331 -0
  126. package/scripts/.claude/settings.local.json +15 -0
  127. package/scripts/README.md +170 -0
  128. package/scripts/RESTART_EVERYTHING.sh +104 -0
  129. package/scripts/add-claim-columns.sql +48 -0
  130. package/scripts/add-crypto-prices-cache.sql +27 -0
  131. package/scripts/add-exchange-rates-cache.sql +40 -0
  132. package/scripts/add-game-invite-column.sql +23 -0
  133. package/scripts/add-game-invite-notification.sql +33 -0
  134. package/scripts/add-game-invite-telegram-pref.sql +16 -0
  135. package/scripts/add-game-joined-notification.sql +16 -0
  136. package/scripts/add-game-joined-pref.js +40 -0
  137. package/scripts/add-game-joined-preference.sql +6 -0
  138. package/scripts/add-game-start-notifications.sql +41 -0
  139. package/scripts/add-notification-flags-to-games.sql +55 -0
  140. package/scripts/add-pending-game-dismissals.sql +19 -0
  141. package/scripts/add-preferred-currency.sql +34 -0
  142. package/scripts/add-winner-columns.js +61 -0
  143. package/scripts/add_mention_system.sql +53 -0
  144. package/scripts/add_payment_system.sql +96 -0
  145. package/scripts/add_sports_event_id_column.sql +22 -0
  146. package/scripts/analyze-cohort-data-heroku.js +276 -0
  147. package/scripts/analyze-cohort-data.js +295 -0
  148. package/scripts/analyze-prod-cohorts.sh +10 -0
  149. package/scripts/backfill-matchup-images.js +245 -0
  150. package/scripts/backfill-missing-signatures.js +175 -0
  151. package/scripts/backfill-referral-earnings.js +202 -0
  152. package/scripts/check-chat-schema.js +130 -0
  153. package/scripts/check-db.sh +14 -0
  154. package/scripts/check_oracle_in_game.js +54 -0
  155. package/scripts/cleanup-database.js +193 -0
  156. package/scripts/clear-notification-cache.js +85 -0
  157. package/scripts/convert-mnemonic.js +50 -0
  158. package/scripts/create-users-table.sql +44 -0
  159. package/scripts/debug-cohort-counts.js +248 -0
  160. package/scripts/debug-winner-calc.js +84 -0
  161. package/scripts/deploy-payment-system.sh +118 -0
  162. package/scripts/deploy-to-heroku.sh +63 -0
  163. package/scripts/diagnose-locked-round.js +143 -0
  164. package/scripts/dubs-cli.js +720 -0
  165. package/scripts/dump-account.js +65 -0
  166. package/scripts/find-vrf-offset.js +48 -0
  167. package/scripts/fix-chat-notifications-constraint.sql +122 -0
  168. package/scripts/fix-claim-columns.js +124 -0
  169. package/scripts/fix-constraint-now.js +44 -0
  170. package/scripts/fix-lock-timestamps.js +96 -0
  171. package/scripts/fix-locked-round.sh +126 -0
  172. package/scripts/fix-missing-badges.sql +91 -0
  173. package/scripts/fix-payment-notifications.sql +41 -0
  174. package/scripts/force-new-round.js +55 -0
  175. package/scripts/force-resolve-and-claim.js +278 -0
  176. package/scripts/important/README.md +115 -0
  177. package/scripts/important/authority-force-lock.js +197 -0
  178. package/scripts/important/authority-resolve-game.js +267 -0
  179. package/scripts/important/check-game-status.js +373 -0
  180. package/scripts/important/list-pending-games-by-version.js +270 -0
  181. package/scripts/important/reconcile-v1-v2-payouts.js +270 -0
  182. package/scripts/initialize-jackpot.js +111 -0
  183. package/scripts/jackpot/.claude/settings.local.json +10 -0
  184. package/scripts/jackpot/force-reset.js +84 -0
  185. package/scripts/jackpot/initialize-mainnet.js +100 -0
  186. package/scripts/jackpot/keeper.js +742 -0
  187. package/scripts/jackpot/status.js +107 -0
  188. package/scripts/jackpot/update-round-duration.js +143 -0
  189. package/scripts/keeper-bot.js +112 -0
  190. package/scripts/list-pending-games.js +131 -0
  191. package/scripts/migrate-chat-v2.js +127 -0
  192. package/scripts/migrate-chat-winners.js +84 -0
  193. package/scripts/migrate-chat.sh +17 -0
  194. package/scripts/migrate-game-invite.js +83 -0
  195. package/scripts/migrate-heroku-game-notifications.sh +159 -0
  196. package/scripts/migrations/001_analytics_tables.sql +422 -0
  197. package/scripts/migrations/002_add_matchup_image_url.sql +14 -0
  198. package/scripts/migrations/003_referral_earnings.sql +208 -0
  199. package/scripts/migrations/004_add_whats_new_notification_type.sql +62 -0
  200. package/scripts/migrations/005_add_connect4_your_turn_notification.sql +61 -0
  201. package/scripts/migrations/005_push_notifications.sql +55 -0
  202. package/scripts/migrations/006_add_draw_team_players.sql +28 -0
  203. package/scripts/migrations/006_add_game_cancelled_notification.sql +62 -0
  204. package/scripts/migrations/007_add_gif_url.sql +8 -0
  205. package/scripts/migrations/008_add_connect4_columns.sql +139 -0
  206. package/scripts/migrations/008_add_pool_tracking.sql +22 -0
  207. package/scripts/migrations/009_create_survivor_pool_tables.sql +174 -0
  208. package/scripts/migrations/010_add_survivor_pool_outcome.sql +28 -0
  209. package/scripts/migrations/011_create_developer_tables.sql +67 -0
  210. package/scripts/migrations/011_fix_keeper_tables.sql +85 -0
  211. package/scripts/migrations/012_create_developer_webhooks.sql +31 -0
  212. package/scripts/migrations/013_add_network_mode.sql +18 -0
  213. package/scripts/migrations/014_create_developer_app_users.sql +19 -0
  214. package/scripts/migrations/015_add_ui_config.sql +4 -0
  215. package/scripts/migrations/016_add_resolution_secret.sql +4 -0
  216. package/scripts/migrations/017_add_external_game_id.sql +3 -0
  217. package/scripts/migrations/018_create_pickem_tables.sql +115 -0
  218. package/scripts/migrations/019_expo_push_tokens.sql +19 -0
  219. package/scripts/migrations/create_whats_new_tables.sql +88 -0
  220. package/scripts/migrations/drop_live_games_tables.sql +34 -0
  221. package/scripts/open-jackpot-round.js +85 -0
  222. package/scripts/purge-all-data.sh +329 -0
  223. package/scripts/purge-all-data.sql +142 -0
  224. package/scripts/purge-heroku-data.sh +149 -0
  225. package/scripts/purge-heroku-data.sql +62 -0
  226. package/scripts/rebuild-heroku-database.sh +113 -0
  227. package/scripts/recover-funds.js +357 -0
  228. package/scripts/regenerate-epl-images.js +278 -0
  229. package/scripts/resize-s3-matchup-images.js +374 -0
  230. package/scripts/resolve-direct.js +88 -0
  231. package/scripts/resolve-mock-game.js +124 -0
  232. package/scripts/resolve-pickem-game.js +55 -0
  233. package/scripts/resolve-round-manual.js +83 -0
  234. package/scripts/resolve-stuck-game.js +382 -0
  235. package/scripts/resolve-stuck-round.js +42 -0
  236. package/scripts/run-connect4-migration.sh +16 -0
  237. package/scripts/run-mention-migration.sh +32 -0
  238. package/scripts/run-payment-migration.sh +51 -0
  239. package/scripts/run-preferred-currency-migration.sh +31 -0
  240. package/scripts/run-referral-earnings-migration.sh +32 -0
  241. package/scripts/run-survivor-outcome-migration.sh +16 -0
  242. package/scripts/seed-test-users.js +346 -0
  243. package/scripts/setup-auth-tables.js +78 -0
  244. package/scripts/setup-complete-database.sql +992 -0
  245. package/scripts/setup-database-fresh.sh +359 -0
  246. package/scripts/setup-heroku-keeper.sh +48 -0
  247. package/scripts/setup-keeper-database.js +83 -0
  248. package/scripts/setup-keeper-state-db.sql +110 -0
  249. package/scripts/setup-oracle.sh +39 -0
  250. package/scripts/setup-pnl-tracking.js +111 -0
  251. package/scripts/start-devnet.sh +14 -0
  252. package/scripts/test-arcade-devnet.sh +160 -0
  253. package/scripts/test-arcade-match.sh +109 -0
  254. package/scripts/test-automatic-mode.sh +239 -0
  255. package/scripts/test-connect4-cancel-claim.js +370 -0
  256. package/scripts/test-connect4-e2e.js +369 -0
  257. package/scripts/test-connect4-resolve.js +369 -0
  258. package/scripts/test-game-state-endpoint.js +136 -0
  259. package/scripts/test-invite-notification.js +86 -0
  260. package/scripts/test-jackpot-api.sh +71 -0
  261. package/scripts/test-poll-confirmation.js +267 -0
  262. package/scripts/test-resolve-game.js +271 -0
  263. package/scripts/test-resolve-signature.js +223 -0
  264. package/scripts/test-signature-preservation.js +124 -0
  265. package/scripts/test-state-machine.js +291 -0
  266. package/scripts/test-webhook-receiver.js +60 -0
  267. package/scripts/update-notification-constraint.js +52 -0
  268. package/scripts/verify-account-layout.js +145 -0
  269. package/scripts/verify-winner-algorithm.js +278 -0
  270. package/server.js +5259 -0
  271. package/services/arcadeMatchService.js +763 -0
  272. package/services/automaticGameOracle.js +1596 -0
  273. package/services/chatService.js +1612 -0
  274. package/services/connect4GameService.js +1049 -0
  275. package/services/connect4NotificationService.js +374 -0
  276. package/services/cryptoPriceService.js +223 -0
  277. package/services/customGameResolver.js +260 -0
  278. package/services/db.js +79 -0
  279. package/services/directMessageService.js +389 -0
  280. package/services/discordNotifications.js +160 -0
  281. package/services/exchangeRateService.js +289 -0
  282. package/services/expoPushService.js +314 -0
  283. package/services/gamesCacheService.js +539 -0
  284. package/services/jackpotHistory.js +331 -0
  285. package/services/jackpotService.js +856 -0
  286. package/services/keeperStateService.js +355 -0
  287. package/services/matchupImageService.js +591 -0
  288. package/services/notificationCacheService.js +407 -0
  289. package/services/pickemOracle.js +440 -0
  290. package/services/playerStatsService.js +389 -0
  291. package/services/portfolioService.js +555 -0
  292. package/services/promoService.js +757 -0
  293. package/services/promoTreasuryService.js +239 -0
  294. package/services/pushNotifications.js +353 -0
  295. package/services/redisService.js +422 -0
  296. package/services/referralEarningsService.js +728 -0
  297. package/services/s3Service.js +396 -0
  298. package/services/socialService.js +1202 -0
  299. package/services/survivorOracle.js +469 -0
  300. package/services/survivorSimulator.js +475 -0
  301. package/services/telegramNotifications.js +461 -0
  302. package/services/userProfileStatsService.js +1185 -0
  303. package/services/whatsNewService.js +388 -0
  304. package/utils/urlHelper.js +95 -0
@@ -0,0 +1,699 @@
1
+ /**
2
+ * Solana Actions Routes - Blink integration for X/Twitter sharing
3
+ *
4
+ * ISOLATED MODULE - Can be safely deleted if not needed
5
+ *
6
+ * This module implements Solana Actions specification to allow users
7
+ * to join bets directly from X/Twitter without visiting the site.
8
+ *
9
+ * Endpoints:
10
+ * GET /api/actions/join-bet/:gameId - Returns action metadata (icon, buttons)
11
+ * POST /api/actions/join-bet/:gameId - Returns signable join transaction
12
+ *
13
+ * Feature flag: ENABLE_SOLANA_ACTIONS=true in .env to enable
14
+ *
15
+ * @see https://solana.com/docs/advanced/actions
16
+ */
17
+
18
+ const express = require('express');
19
+ const router = express.Router();
20
+ const { Connection, PublicKey, SystemProgram, Transaction, TransactionInstruction } = require('@solana/web3.js');
21
+ const crypto = require('crypto');
22
+
23
+ // ============ CONFIGURATION ============
24
+
25
+ const PROGRAM_ID = new PublicKey(process.env.PROGRAM_ID || "85wJGp9uc8w2FeKX9CEHsudTo1UVCrmuRFy37oCcaoG1");
26
+ const LAMPORTS_PER_SOL = 1_000_000_000;
27
+
28
+ // Join automatic game discriminator (must match server.js)
29
+ const JOIN_AUTO = Buffer.from([87, 51, 29, 81, 147, 216, 222, 119]);
30
+
31
+ // Default bet amounts for action buttons (in SOL)
32
+ const DEFAULT_BET_AMOUNTS = [0.05, 0.1, 0.25];
33
+
34
+ // ============ SOCKET.IO INJECTION ============
35
+
36
+ let chatNamespace = null;
37
+
38
+ // Inject Socket.IO instance (called from server.js)
39
+ router.setSocketIO = (ioInstance, chatNS) => {
40
+ chatNamespace = chatNS;
41
+ console.log('🔌 Socket.IO injected into actions routes');
42
+ };
43
+
44
+ // ============ HELPER FUNCTIONS (copied to keep isolated) ============
45
+
46
+ function uuidToU64(uuid) {
47
+ const hash = crypto.createHash('sha256').update(uuid).digest();
48
+ return hash.readBigUInt64LE(0);
49
+ }
50
+
51
+ function getGamePDA(gameId) {
52
+ let gameIdNum;
53
+ if (typeof gameId === 'string' && gameId.includes('-')) {
54
+ gameIdNum = uuidToU64(gameId);
55
+ } else {
56
+ gameIdNum = BigInt(gameId);
57
+ }
58
+ const gameIdBuf = Buffer.alloc(8);
59
+ gameIdBuf.writeBigUInt64LE(gameIdNum);
60
+ return PublicKey.findProgramAddressSync([Buffer.from("game"), gameIdBuf], PROGRAM_ID);
61
+ }
62
+
63
+ function getGameIdBuffer(gameId) {
64
+ let gameIdNum;
65
+ if (typeof gameId === 'string' && gameId.includes('-')) {
66
+ gameIdNum = uuidToU64(gameId);
67
+ } else {
68
+ gameIdNum = BigInt(gameId);
69
+ }
70
+ const buf = Buffer.alloc(8);
71
+ buf.writeBigUInt64LE(gameIdNum);
72
+ return buf;
73
+ }
74
+
75
+ // ============ CORS MIDDLEWARE FOR ACTIONS ============
76
+
77
+ /**
78
+ * Solana Actions require specific CORS headers
79
+ * This middleware adds them to all routes in this file
80
+ */
81
+ router.use((req, res, next) => {
82
+ // CORS headers required by Solana Actions spec
83
+ res.setHeader('Access-Control-Allow-Origin', '*');
84
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, OPTIONS');
85
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Content-Encoding, Accept-Encoding, Accept, X-Requested-With');
86
+ res.setHeader('Access-Control-Expose-Headers', 'X-Action-Version, X-Blockchain-Ids');
87
+ res.setHeader('X-Action-Version', '2.2');
88
+ // Set blockchain ID based on environment (devnet vs mainnet)
89
+ const isDevnet = (process.env.SOLANA_NETWORK || '').includes('devnet');
90
+ const blockchainId = isDevnet
91
+ ? 'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1' // devnet
92
+ : 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'; // mainnet
93
+ res.setHeader('X-Blockchain-Ids', blockchainId);
94
+
95
+ if (req.method === 'OPTIONS') {
96
+ return res.status(200).end();
97
+ }
98
+ next();
99
+ });
100
+
101
+ // ============ FACTORY FUNCTION ============
102
+
103
+ /**
104
+ * Create actions routes with database pool injection
105
+ * @param {Pool} pool - PostgreSQL connection pool
106
+ * @param {Connection} connection - Solana RPC connection
107
+ */
108
+ function createActionsRoutes(pool, connection) {
109
+
110
+ // ============ AUTO-CREATE USER FOR BLINK JOINS ============
111
+
112
+ /**
113
+ * Find or create a minimal user record for Blink joins
114
+ * Users created this way are marked as needing onboarding
115
+ */
116
+ async function findOrCreateBlinkUser(walletAddress) {
117
+ try {
118
+ // Check if user exists
119
+ const existingUser = await pool.query(
120
+ 'SELECT id, username FROM users WHERE wallet_address = $1',
121
+ [walletAddress]
122
+ );
123
+
124
+ if (existingUser.rows.length > 0) {
125
+ return { exists: true, user: existingUser.rows[0] };
126
+ }
127
+
128
+ // Create minimal user for Blink join
129
+ // Uses existing columns only - no schema changes needed
130
+ const shortWallet = walletAddress.slice(0, 6);
131
+ const autoUsername = `blink_${shortWallet}`;
132
+
133
+ const result = await pool.query(
134
+ `INSERT INTO users
135
+ (wallet_address, username, created_at, onboarding_complete)
136
+ VALUES ($1, $2, NOW(), false)
137
+ ON CONFLICT (wallet_address) DO NOTHING
138
+ RETURNING id, username`,
139
+ [walletAddress, autoUsername]
140
+ );
141
+
142
+ if (result.rows.length > 0) {
143
+ console.log(`[Actions] Created Blink user: ${autoUsername} (${walletAddress.slice(0, 8)}...)`);
144
+ return { exists: false, created: true, user: result.rows[0] };
145
+ }
146
+
147
+ // Race condition: user was created between check and insert
148
+ const raceUser = await pool.query(
149
+ 'SELECT id, username FROM users WHERE wallet_address = $1',
150
+ [walletAddress]
151
+ );
152
+ return { exists: true, user: raceUser.rows[0] };
153
+
154
+ } catch (error) {
155
+ console.error('[Actions] Error in findOrCreateBlinkUser:', error.message);
156
+ // Don't fail the transaction - just log and continue
157
+ return { exists: false, created: false, error: error.message };
158
+ }
159
+ }
160
+
161
+ // ============ GET ENDPOINT - ACTION METADATA ============
162
+
163
+ /**
164
+ * GET /api/actions/join-bet/:gameId
165
+ * Returns action metadata for rendering the Blink UI
166
+ */
167
+ router.get('/join-bet/:gameId', async (req, res) => {
168
+ try {
169
+ const { gameId } = req.params;
170
+
171
+ // Fetch game from database
172
+ const gameResult = await pool.query(
173
+ `SELECT
174
+ game_id, title, buy_in, is_locked, is_resolved,
175
+ matchup_image_url, sports_event, lock_timestamp,
176
+ home_team_players, away_team_players, draw_team_players,
177
+ total_pool, home_pool, away_pool, draw_pool
178
+ FROM games WHERE game_id = $1`,
179
+ [gameId]
180
+ );
181
+
182
+ if (gameResult.rows.length === 0) {
183
+ return res.status(404).json({
184
+ icon: 'https://dubs.app/logo.png',
185
+ title: 'Game Not Found',
186
+ description: 'This bet no longer exists',
187
+ label: 'Not Found',
188
+ disabled: true,
189
+ error: { message: 'Game not found' }
190
+ });
191
+ }
192
+
193
+ const game = gameResult.rows[0];
194
+ const sportsEvent = game.sports_event || {};
195
+
196
+ // Check if game is joinable
197
+ if (game.is_locked) {
198
+ return res.json({
199
+ icon: game.matchup_image_url || 'https://dubs.app/logo.png',
200
+ title: game.title || 'Betting Closed',
201
+ description: 'Betting has closed for this game',
202
+ label: 'Closed',
203
+ disabled: true,
204
+ error: { message: 'Betting is closed' }
205
+ });
206
+ }
207
+
208
+ if (game.is_resolved) {
209
+ return res.json({
210
+ icon: game.matchup_image_url || 'https://dubs.app/logo.png',
211
+ title: game.title || 'Game Finished',
212
+ description: 'This game has already been resolved',
213
+ label: 'Finished',
214
+ disabled: true,
215
+ error: { message: 'Game is finished' }
216
+ });
217
+ }
218
+
219
+ // Extract team names
220
+ const homeTeam = sportsEvent.strHomeTeam || 'Home';
221
+ const awayTeam = sportsEvent.strAwayTeam || 'Away';
222
+ const hasDrawOption = sportsEvent.strSport === 'Soccer' ||
223
+ sportsEvent.strLeague?.includes('MLS') ||
224
+ sportsEvent.strLeague?.includes('Premier League');
225
+
226
+ // Build base URL from request (required for absolute hrefs)
227
+ const protocol = req.headers['x-forwarded-proto'] || req.protocol || 'https';
228
+ const host = req.headers['x-forwarded-host'] || req.headers.host;
229
+ const baseUrl = `${protocol}://${host}`;
230
+
231
+ // Build action links for each team
232
+ const actions = [];
233
+
234
+ // Home team action with amount parameter
235
+ actions.push({
236
+ type: 'transaction',
237
+ label: `${homeTeam}`,
238
+ href: `${baseUrl}/api/actions/join-bet/${gameId}?team=home&amount={amount}`,
239
+ parameters: [
240
+ {
241
+ name: 'amount',
242
+ label: 'Bet Amount (SOL)',
243
+ required: true,
244
+ type: 'number',
245
+ min: 0.01,
246
+ max: 10,
247
+ }
248
+ ]
249
+ });
250
+
251
+ // Away team action with amount parameter
252
+ actions.push({
253
+ type: 'transaction',
254
+ label: `${awayTeam}`,
255
+ href: `${baseUrl}/api/actions/join-bet/${gameId}?team=away&amount={amount}`,
256
+ parameters: [
257
+ {
258
+ name: 'amount',
259
+ label: 'Bet Amount (SOL)',
260
+ required: true,
261
+ type: 'number',
262
+ min: 0.01,
263
+ max: 10,
264
+ }
265
+ ]
266
+ });
267
+
268
+ // Draw option for soccer
269
+ if (hasDrawOption) {
270
+ actions.push({
271
+ type: 'transaction',
272
+ label: 'Draw',
273
+ href: `${baseUrl}/api/actions/join-bet/${gameId}?team=draw&amount={amount}`,
274
+ parameters: [
275
+ {
276
+ name: 'amount',
277
+ label: 'Bet Amount (SOL)',
278
+ required: true,
279
+ type: 'number',
280
+ min: 0.01,
281
+ max: 10,
282
+ }
283
+ ]
284
+ });
285
+ }
286
+
287
+ // Format pool info for description
288
+ const totalPool = parseFloat(game.total_pool) || 0;
289
+ const playerCount = (game.home_team_players?.length || 0) +
290
+ (game.away_team_players?.length || 0) +
291
+ (game.draw_team_players?.length || 0);
292
+
293
+ const description = totalPool > 0
294
+ ? `${playerCount} player${playerCount !== 1 ? 's' : ''} | ${totalPool.toFixed(2)} SOL pot`
295
+ : 'Be the first to bet!';
296
+
297
+ // Return action metadata
298
+ res.json({
299
+ type: 'action',
300
+ icon: game.matchup_image_url || 'https://dubs.app/logo.png',
301
+ title: game.title || `${homeTeam} vs ${awayTeam}`,
302
+ description: description,
303
+ label: 'Place Bet',
304
+ links: {
305
+ actions: actions
306
+ }
307
+ });
308
+
309
+ } catch (error) {
310
+ console.error('[Actions] GET error:', error);
311
+ res.status(500).json({
312
+ icon: 'https://dubs.app/logo.png',
313
+ title: 'Error',
314
+ description: 'Failed to load game',
315
+ label: 'Error',
316
+ disabled: true,
317
+ error: { message: error.message }
318
+ });
319
+ }
320
+ });
321
+
322
+ // ============ POST ENDPOINT - BUILD TRANSACTION ============
323
+
324
+ /**
325
+ * POST /api/actions/join-bet/:gameId
326
+ * Returns a signable transaction to join the bet
327
+ */
328
+ router.post('/join-bet/:gameId', async (req, res) => {
329
+ try {
330
+ const { gameId } = req.params;
331
+ const { team, amount } = req.query;
332
+ const { account } = req.body; // User's wallet address (base58)
333
+
334
+ // Build base URL for callback
335
+ const protocol = req.headers['x-forwarded-proto'] || req.protocol || 'https';
336
+ const host = req.headers['x-forwarded-host'] || req.headers.host;
337
+ const baseUrl = `${protocol}://${host}`;
338
+
339
+ // Validate inputs
340
+ if (!account) {
341
+ return res.status(400).json({
342
+ error: { message: 'Wallet account is required' }
343
+ });
344
+ }
345
+
346
+ if (!team || !['home', 'away', 'draw'].includes(team)) {
347
+ return res.status(400).json({
348
+ error: { message: 'Team must be home, away, or draw' }
349
+ });
350
+ }
351
+
352
+ const betAmount = parseFloat(amount);
353
+ if (!betAmount || betAmount <= 0 || betAmount > 10) {
354
+ return res.status(400).json({
355
+ error: { message: 'Amount must be between 0.01 and 10 SOL' }
356
+ });
357
+ }
358
+
359
+ // Validate wallet address format
360
+ let playerPubkey;
361
+ try {
362
+ playerPubkey = new PublicKey(account);
363
+ } catch (e) {
364
+ return res.status(400).json({
365
+ error: { message: 'Invalid wallet address' }
366
+ });
367
+ }
368
+
369
+ // Verify game exists and is joinable
370
+ const gameResult = await pool.query(
371
+ `SELECT game_id, is_locked, is_resolved, title, sports_event,
372
+ home_team_players, away_team_players, draw_team_players
373
+ FROM games WHERE game_id = $1`,
374
+ [gameId]
375
+ );
376
+
377
+ if (gameResult.rows.length === 0) {
378
+ return res.status(404).json({
379
+ type: 'action',
380
+ icon: 'https://dubs.app/logo.png',
381
+ title: 'Game Not Found',
382
+ description: 'This bet no longer exists',
383
+ label: 'Not Found',
384
+ disabled: true,
385
+ error: { message: 'Game not found' }
386
+ });
387
+ }
388
+
389
+ const game = gameResult.rows[0];
390
+
391
+ if (game.is_locked) {
392
+ return res.status(400).json({
393
+ type: 'action',
394
+ icon: game.matchup_image_url || 'https://dubs.app/logo.png',
395
+ title: 'Betting Closed',
396
+ description: 'Betting has closed for this game',
397
+ label: 'Closed',
398
+ disabled: true,
399
+ error: { message: 'Betting has closed for this game' }
400
+ });
401
+ }
402
+
403
+ if (game.is_resolved) {
404
+ return res.status(400).json({
405
+ type: 'action',
406
+ icon: game.matchup_image_url || 'https://dubs.app/logo.png',
407
+ title: 'Game Finished',
408
+ description: 'This game has already been resolved',
409
+ label: 'Finished',
410
+ disabled: true,
411
+ error: { message: 'This game has already been resolved' }
412
+ });
413
+ }
414
+
415
+ // Check if player already joined
416
+ const allPlayers = [
417
+ ...(game.home_team_players || []),
418
+ ...(game.away_team_players || []),
419
+ ...(game.draw_team_players || [])
420
+ ];
421
+
422
+ if (allPlayers.includes(account)) {
423
+ return res.status(400).json({
424
+ type: 'action',
425
+ icon: game.matchup_image_url || 'https://dubs.app/logo.png',
426
+ title: 'Already Joined',
427
+ description: 'You have already joined this bet',
428
+ label: 'Already Joined',
429
+ disabled: true,
430
+ error: { message: 'You have already joined this bet' }
431
+ });
432
+ }
433
+
434
+ // Auto-create user if needed (for tracking/notifications)
435
+ await findOrCreateBlinkUser(account);
436
+
437
+ // Build the join transaction
438
+ const [gamePDA] = getGamePDA(gameId);
439
+ const gameIdBuf = getGameIdBuffer(gameId);
440
+
441
+ // Convert amount to lamports
442
+ const amountBuf = Buffer.alloc(8);
443
+ amountBuf.writeBigUInt64LE(BigInt(Math.round(betAmount * LAMPORTS_PER_SOL)));
444
+
445
+ // Encode team choice: 0 = Home, 1 = Away, 2 = Draw
446
+ const teamChoiceByte = team === 'home' ? 0 : team === 'away' ? 1 : 2;
447
+
448
+ // Build instruction data for join_automatic_game
449
+ const data = Buffer.concat([
450
+ JOIN_AUTO,
451
+ gameIdBuf,
452
+ Buffer.from([teamChoiceByte]),
453
+ amountBuf
454
+ ]);
455
+
456
+ const instruction = new TransactionInstruction({
457
+ keys: [
458
+ { pubkey: gamePDA, isSigner: false, isWritable: true },
459
+ { pubkey: playerPubkey, isSigner: true, isWritable: true },
460
+ { pubkey: SystemProgram.programId, isSigner: false, isWritable: false },
461
+ ],
462
+ programId: PROGRAM_ID,
463
+ data,
464
+ });
465
+
466
+ const transaction = new Transaction().add(instruction);
467
+ transaction.recentBlockhash = (await connection.getLatestBlockhash()).blockhash;
468
+ transaction.feePayer = playerPubkey;
469
+
470
+ const serialized = transaction.serialize({ requireAllSignatures: false }).toString('base64');
471
+
472
+ // Get team name for message
473
+ const sportsEvent = game.sports_event || {};
474
+ const teamName = team === 'home' ? (sportsEvent.strHomeTeam || 'Home') :
475
+ team === 'away' ? (sportsEvent.strAwayTeam || 'Away') : 'Draw';
476
+
477
+ console.log(`[Actions] Built join tx for ${account.slice(0, 8)}... | ${betAmount} SOL on ${teamName} | Game: ${gameId.slice(-8)}`);
478
+
479
+ res.json({
480
+ type: 'transaction',
481
+ transaction: serialized,
482
+ message: `Bet ${betAmount} SOL on ${teamName}`,
483
+ links: {
484
+ next: {
485
+ type: 'post',
486
+ href: `${baseUrl}/api/actions/confirm-join/${gameId}?team=${team}&amount=${betAmount}`
487
+ }
488
+ }
489
+ });
490
+
491
+ } catch (error) {
492
+ console.error('[Actions] POST error:', error);
493
+ res.status(500).json({
494
+ error: { message: error.message || 'Failed to build transaction' }
495
+ });
496
+ }
497
+ });
498
+
499
+ // ============ CONFIRM ENDPOINT - POST-TRANSACTION DATABASE UPDATE ============
500
+
501
+ /**
502
+ * POST /api/actions/confirm-join/:gameId
503
+ * Called by Blink client after transaction confirms - updates the database
504
+ */
505
+ router.post('/confirm-join/:gameId', async (req, res) => {
506
+ try {
507
+ const { gameId } = req.params;
508
+ const { team, amount } = req.query;
509
+ const { account, signature } = req.body; // account + signature from Blink client
510
+
511
+ console.log(`[Actions] Confirm join: ${account?.slice(0, 8)}... | Game: ${gameId.slice(-8)} | Sig: ${signature?.slice(0, 16)}...`);
512
+
513
+ if (!account || !signature) {
514
+ return res.status(400).json({
515
+ error: { message: 'Account and signature are required' }
516
+ });
517
+ }
518
+
519
+ const wallet = account;
520
+
521
+ const betAmount = parseFloat(amount) || 0;
522
+
523
+ // Verify team choice from on-chain transaction (source of truth)
524
+ // The query param `team` can diverge from what was actually signed on-chain
525
+ let verifiedTeam = team;
526
+ try {
527
+ const txInfo = await connection.getTransaction(signature, {
528
+ commitment: 'confirmed',
529
+ maxSupportedTransactionVersion: 0
530
+ });
531
+ if (txInfo?.transaction?.message) {
532
+ const msg = txInfo.transaction.message;
533
+ const programIdStr = PROGRAM_ID.toString();
534
+ const accountKeys = msg.staticAccountKeys || msg.accountKeys || [];
535
+ const instructions = msg.compiledInstructions || msg.instructions || [];
536
+ for (const ix of instructions) {
537
+ const progIdx = ix.programIdIndex;
538
+ const progKey = accountKeys[progIdx]?.toString();
539
+ if (progKey === programIdStr) {
540
+ const data = Buffer.from(ix.data);
541
+ // join_automatic_game: 8-byte discriminator + 8-byte gameId + 1-byte teamChoice
542
+ if (data.length >= 17 && data.slice(0, 8).equals(JOIN_AUTO)) {
543
+ const teamByte = data[16];
544
+ const onChainTeam = teamByte === 0 ? 'home' : teamByte === 1 ? 'away' : 'draw';
545
+ if (onChainTeam !== team) {
546
+ console.warn(`[Actions] ⚠️ TEAM MISMATCH: query=${team}, on-chain=${onChainTeam} | Wallet: ${wallet.slice(0, 8)}... | Game: ${gameId}`);
547
+ }
548
+ verifiedTeam = onChainTeam;
549
+ break;
550
+ }
551
+ }
552
+ }
553
+ }
554
+ } catch (err) {
555
+ console.warn(`[Actions] Could not verify on-chain team choice, using query param: ${err.message}`);
556
+ }
557
+
558
+ const teamChoice = verifiedTeam || 'home';
559
+
560
+ // Determine which fields to update
561
+ const teamField = teamChoice === 'home' ? 'home_team_players'
562
+ : teamChoice === 'away' ? 'away_team_players'
563
+ : 'draw_team_players';
564
+
565
+ const poolField = teamChoice === 'home' ? 'home_pool'
566
+ : teamChoice === 'away' ? 'away_pool'
567
+ : 'draw_pool';
568
+
569
+ // Update game with new player (same logic as gamesRoutes join endpoint)
570
+ await pool.query(`
571
+ UPDATE games
572
+ SET ${teamField} = array_append(${teamField}, $1),
573
+ ${poolField} = COALESCE(${poolField}, 0) + $3,
574
+ total_pool = COALESCE(total_pool, 0) + $3,
575
+ player_amounts = COALESCE(player_amounts, '{}'::jsonb) || jsonb_build_object($1, $3),
576
+ updated_at = NOW()
577
+ WHERE game_id = $2
578
+ AND NOT ($1 = ANY(COALESCE(home_team_players, ARRAY[]::text[]))
579
+ OR $1 = ANY(COALESCE(away_team_players, ARRAY[]::text[]))
580
+ OR $1 = ANY(COALESCE(draw_team_players, ARRAY[]::text[])))
581
+ `, [wallet, gameId, betAmount]);
582
+
583
+ // Save user's game reference
584
+ await pool.query(`
585
+ INSERT INTO user_game_refs (
586
+ wallet_address, game_id, role, joined_at, team_choice,
587
+ my_signature, my_explorer_url, status, wallet_type
588
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
589
+ ON CONFLICT (wallet_address, game_id)
590
+ DO UPDATE SET
591
+ team_choice = EXCLUDED.team_choice,
592
+ my_signature = COALESCE(EXCLUDED.my_signature, user_game_refs.my_signature),
593
+ status = EXCLUDED.status
594
+ `, [
595
+ wallet,
596
+ gameId,
597
+ 'player',
598
+ new Date().toISOString(),
599
+ teamChoice,
600
+ signature,
601
+ `https://explorer.solana.com/tx/${signature}${(process.env.SOLANA_NETWORK || '').includes('devnet') ? '?cluster=devnet' : ''}`,
602
+ 'active',
603
+ 'blink'
604
+ ]);
605
+
606
+ console.log(`[Actions] ✅ Database updated for Blink join: ${wallet.slice(0, 8)}... -> ${teamChoice} team`);
607
+
608
+ // Get updated game data for WebSocket broadcast
609
+ const gameResult = await pool.query('SELECT * FROM games WHERE game_id = $1', [gameId]);
610
+ const updatedGame = gameResult.rows[0];
611
+
612
+ // Get user info for notifications
613
+ const userResult = await pool.query('SELECT id, username FROM users WHERE wallet_address = $1', [wallet]);
614
+ const user = userResult.rows[0];
615
+ const joinerUsername = user?.username || `blink_${wallet.slice(0, 6)}`;
616
+
617
+ // Emit WebSocket event to all connected clients
618
+ if (chatNamespace && updatedGame) {
619
+ chatNamespace.emit('game:player_joined', {
620
+ gameId: gameId,
621
+ walletAddress: wallet,
622
+ teamChoice: teamChoice,
623
+ homeTeamCount: updatedGame.home_team_players?.length || 0,
624
+ awayTeamCount: updatedGame.away_team_players?.length || 0,
625
+ drawTeamCount: updatedGame.draw_team_players?.length || 0,
626
+ totalPlayers: (updatedGame.home_team_players?.length || 0) + (updatedGame.away_team_players?.length || 0) + (updatedGame.draw_team_players?.length || 0),
627
+ timestamp: Date.now(),
628
+ source: 'blink'
629
+ });
630
+ console.log(`[Actions] 📡 Broadcasted game:player_joined event`);
631
+ }
632
+
633
+ // Send notifications to other participants
634
+ try {
635
+ const { forwardChatNotification } = require('../services/telegramNotifications');
636
+
637
+ // Get all participants except the joiner
638
+ const allParticipants = [
639
+ ...(updatedGame.home_team_players || []),
640
+ ...(updatedGame.away_team_players || []),
641
+ ...(updatedGame.draw_team_players || []),
642
+ updatedGame.created_by
643
+ ].filter(w => w && w !== wallet);
644
+
645
+ const uniqueParticipants = [...new Set(allParticipants)];
646
+
647
+ // Get participant user IDs
648
+ if (uniqueParticipants.length > 0) {
649
+ const participantsResult = await pool.query(
650
+ 'SELECT id, wallet_address FROM users WHERE wallet_address = ANY($1)',
651
+ [uniqueParticipants]
652
+ );
653
+
654
+ for (const participant of participantsResult.rows) {
655
+ const notification = {
656
+ type: 'player_joined',
657
+ title: '🎮 New Player Joined!',
658
+ body: `${joinerUsername} joined your bet with ${betAmount} SOL`,
659
+ gameId: gameId,
660
+ data: { gameId, joinerUsername, teamChoice, betAmount }
661
+ };
662
+
663
+ await forwardChatNotification(participant.id, notification).catch(err => {
664
+ console.log(`[Actions] Notification to ${participant.wallet_address.slice(0, 8)} failed:`, err.message);
665
+ });
666
+ }
667
+ console.log(`[Actions] 📬 Sent notifications to ${participantsResult.rows.length} participant(s)`);
668
+ }
669
+ } catch (notifError) {
670
+ console.error('[Actions] Notification error (non-fatal):', notifError.message);
671
+ }
672
+
673
+ // Return a completed action response with all required fields
674
+ res.json({
675
+ type: 'completed',
676
+ icon: 'https://dubs.app/logo.png',
677
+ title: 'Bet Placed!',
678
+ description: `Successfully bet ${betAmount} SOL on ${teamChoice}`,
679
+ label: 'Complete'
680
+ });
681
+
682
+ } catch (error) {
683
+ console.error('[Actions] Confirm error:', error);
684
+ res.status(500).json({
685
+ type: 'action',
686
+ icon: 'https://dubs.app/logo.png',
687
+ title: 'Error',
688
+ description: error.message || 'Failed to confirm join',
689
+ label: 'Error',
690
+ disabled: true,
691
+ error: { message: error.message || 'Failed to confirm join' }
692
+ });
693
+ }
694
+ });
695
+
696
+ return router;
697
+ }
698
+
699
+ module.exports = createActionsRoutes;